react-doctor 0.9.7-dev.13138a4 → 0.9.7

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.
@@ -1,3075 +0,0 @@
1
-
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="3db0a4af-3830-56c7-a8d6-a6f446bd2cf2")}catch(e){}}();
3
- import { $ as Files, $n as withTracer, $t as hashFileContents, An as summarizeDiagnostics, B as isCiOrCodingAgentEnvironment, Bn as Diagnostic, Bt as filterSourceFiles, Cn as resolveReactDoctorCacheDir, Ct as SupplyChain, Dn as runInspect, Dt as buildSkippedChecks, En as restoreLegacyThrow, Et as buildRuleDocsUrl, Fn as Console, Ft as createOxlintSpawnSlots, Gn as promise, Hn as gen, In as error, J as Config, Jn as runPromise, Kn as provide, L as ACTION_INPUT_ENVIRONMENT_VARIABLES, Lt as discoverReactSubprojects, Mt as computeConfigFingerprint, N as CliInputError, Nt as computeDiagnosticDelta, Ot as calculateScore, P as isNonInteractiveEnvironment, Pn as yieldToEventLoop, Pt as createNodeReadFileLinesSync, Q as ENTERPRISE_CONTACT_URL, R as detectRunnerOs, Rn as log, Rt as filterDiagnosticsForSurface, Sn as resolveLintBatchOrdering, Tn as resolveScanTarget, Un as map, Vn as decodeUnknownSync, W as CI_URL, Wn as orElseSucceed, Xn as sync, Xt as groupBy, Y as DIAGNOSTIC_CATEGORY_BUCKETS, Yn as runSync, Z as DeadCode, Zn as try_, Zt as hasPublishedFixRecipe, _n as mergeReactDoctorConfigs, _t as SHARE_BASE_URL, a as buildSentryProjectContext, ar as externalSpan, bt as Score, cn as isReactDoctorError, ct as MILLISECONDS_PER_SECOND, dn as layerOtlp, dt as OxlintConcurrency, en as highlighter, er as mergeAll, fn as listSourceFiles, ft as OxlintSpawnSlots, g as BASELINE_FILES_TEMP_DIR_PREFIX, gn as materializeSourceTree, gt as Reporter, h as makeNoopConsole, hn as mapWithConcurrency, ht as Project, i as buildSentryScope, in as isMonorepoRoot, ir as ParentSpan, it as JSX_FILE_PATTERN, jt as collectSourceFileCountsByDirectory, kt as canonicalizeUserRuleKey, l as isJsonModeActive, lt as NodeResolver, m as VERSION, mt as Progress, n as recordDistribution, nr as succeed, nt as Git, o as setSentryProjectInfo, or as make, ot as LintPartialFailures, pn as listWorkspacePackages, pt as PerFileLintCacheEnabled, qn as provideService, qt as getDiagnosticRuleIdentity, r as toSpanAttributes, rn as isFile, rr as isSuccess, rt as HTML_FILE_PATTERN, s as getRunId, sr as isSome, st as Linter, t as recordCount, tn as isDirectory, tt as GIT_SHOW_MAX_BUFFER_BYTES, un as isScanComplete, ut as OXLINT_NODE_REQUIREMENT, w as SCAN_RESULT_CACHE_FILENAME, wn as resolveScanConcurrency, wt as atomicWriteJson, x as NANOSECONDS_PER_SECOND, xn as resolveGithubActionsScoreMetadata, xt as SidecarLintCacheEnabled, y as METRIC, z as isCiEnvironment, zn as warn, zt as filterPathsOutsideDirectories } from "./record-metric-CtlQma6P.js";
4
- import { n as buildFooterLinkLines, o as isSentryTracingEnabled, r as activeScanAbortRegistry, t as buildSectionDivider } from "./build-section-divider-QykRnrlR.js";
5
- import { a as setActiveRunTrace, i as recordRunTraceId } from "./unref-stdin-B5RnVeLa.js";
6
- import { D as getPackageJsonPath, L as cliLogger, O as isRecord, P as runGit, a as installReactDoctorScriptStep, c as spinner, n as findNearestPackageDirectory, o as isSpinnerSilent, s as setSpinnerSilent } from "./install-doctor-script-DxrteuEw.js";
7
- import { d as detectDefaultBranch, l as prompts, r as installReactDoctorWorkflow, u as isCommandAvailable } from "./install-github-workflow-BP6t3YBA.js";
8
- import { a as isValidBlockingLevel, c as openWorkflowPullRequest, l as stageWorkflowFile, o as pickBlockingLevel, u as toForwardSlashes } from "./resolve-scope-gy72JH3h.js";
9
- import { h as detectAvailableAgents, t as reportWorkflowResult } from "./report-workflow-result-D3GCowCN.js";
10
- import { createRequire } from "node:module";
11
- import { spawn } from "node:child_process";
12
- import * as path$1 from "node:path";
13
- import path from "node:path";
14
- import * as fs$1 from "node:fs";
15
- import fs, { mkdtempSync, rmSync } from "node:fs";
16
- import crypto, { randomUUID } from "node:crypto";
17
- import ts from "typescript";
18
- import { tmpdir } from "node:os";
19
- import * as Sentry from "@sentry/node";
20
- import { performance } from "node:perf_hooks";
21
- import { codeFrameColumns } from "@babel/code-frame";
22
- //#region src/cli/utils/sentry-tracer.ts
23
- const toHrTime = (epochNanoseconds) => [Number(epochNanoseconds / NANOSECONDS_PER_SECOND), Number(epochNanoseconds % NANOSECONDS_PER_SECOND)];
24
- const SPAN_KIND_TO_OTEL = {
25
- internal: 0,
26
- server: 1,
27
- client: 2,
28
- producer: 3,
29
- consumer: 4
30
- };
31
- const toSentryAttributeValue = (value) => {
32
- if (value === null || value === void 0) return void 0;
33
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
34
- return String(value);
35
- };
36
- const normalizeAttributes = (attributes) => {
37
- const normalized = {};
38
- if (!attributes) return normalized;
39
- for (const [key, value] of Object.entries(attributes)) normalized[key] = toSentryAttributeValue(value);
40
- return normalized;
41
- };
42
- const isSentryBackedSpan = (span) => span._tag === "Span" && "sentrySpan" in span;
43
- const spanContextFor = (span) => isSentryBackedSpan(span) ? span.sentrySpan.spanContext() : {
44
- traceId: span.traceId,
45
- spanId: span.spanId,
46
- traceFlags: span.sampled ? 1 : 0
47
- };
48
- /**
49
- * Builds an Effect {@link Tracer.Tracer} that materializes every Effect span
50
- * (`Effect.withSpan(...)` / `Effect.fn("Service.method")`) as a child Sentry
51
- * span, producing one unified per-run trace in Sentry. The CLI already
52
- * instruments `runInspect` and each core service method, so this bridge lights
53
- * all of that up in Sentry for free.
54
- *
55
- * `rootSpan` is the active per-run transaction; Effect spans without an Effect
56
- * parent attach to it, so nesting is correct even if async-context propagation
57
- * is interrupted by Effect's fiber scheduler. Provided to a program via
58
- * `Effect.withTracer(...)`.
59
- */
60
- const makeSentryTracer = (rootSpan, startInactiveSpan = Sentry.startInactiveSpan) => make({ span: (options) => {
61
- const parentSpan = isSome(options.parent) && isSentryBackedSpan(options.parent.value) ? options.parent.value.sentrySpan : rootSpan;
62
- const sentrySpan = startInactiveSpan({
63
- name: options.name,
64
- startTime: toHrTime(options.startTime),
65
- parentSpan,
66
- kind: SPAN_KIND_TO_OTEL[options.kind]
67
- });
68
- const { traceId, spanId } = sentrySpan.spanContext();
69
- const attributes = /* @__PURE__ */ new Map();
70
- let status = {
71
- _tag: "Started",
72
- startTime: options.startTime
73
- };
74
- return {
75
- _tag: "Span",
76
- sentrySpan,
77
- name: options.name,
78
- spanId,
79
- traceId,
80
- parent: options.parent,
81
- annotations: options.annotations,
82
- links: options.links,
83
- sampled: options.sampled,
84
- kind: options.kind,
85
- get status() {
86
- return status;
87
- },
88
- get attributes() {
89
- return attributes;
90
- },
91
- end: (endTime, exit) => {
92
- status = {
93
- _tag: "Ended",
94
- startTime: options.startTime,
95
- endTime,
96
- exit
97
- };
98
- sentrySpan.setStatus({ code: isSuccess(exit) ? 1 : 2 });
99
- sentrySpan.end(toHrTime(endTime));
100
- },
101
- attribute: (key, value) => {
102
- attributes.set(key, value);
103
- sentrySpan.setAttribute(key, toSentryAttributeValue(value));
104
- },
105
- event: (name, startTime, eventAttributes) => {
106
- sentrySpan.addEvent(name, normalizeAttributes(eventAttributes), toHrTime(startTime));
107
- },
108
- addLinks: (links) => {
109
- for (const link of links) sentrySpan.addLink({
110
- context: spanContextFor(link.span),
111
- attributes: normalizeAttributes(link.attributes)
112
- });
113
- }
114
- };
115
- } });
116
- //#endregion
117
- //#region src/cli/utils/apply-observability.ts
118
- const isOtlpExportConfigured = () => Boolean(process.env.REACT_DOCTOR_OTLP_ENDPOINT) && Boolean(process.env.REACT_DOCTOR_OTLP_AUTH_HEADER);
119
- const externalSpanFrom = (sentrySpan) => {
120
- const { traceId, spanId, traceFlags } = sentrySpan.spanContext();
121
- return externalSpan({
122
- traceId,
123
- spanId,
124
- sampled: (traceFlags & 1) === 1
125
- });
126
- };
127
- /**
128
- * Installs the tracing backend for the inspect program. Effect's tracer is a
129
- * single reference, so the backends are mutually exclusive — we pick by
130
- * precedence:
131
- *
132
- * 1. **User OTLP backend** (`REACT_DOCTOR_OTLP_*` set) wins; we additionally
133
- * parent the Effect trace under the active Sentry trace via an
134
- * `ExternalSpan` so a trace exported to the user's backend shares its
135
- * `trace_id` with the corresponding Sentry trace.
136
- * 2. **Sentry tracing active** (and no user OTLP): route Effect's existing
137
- * span instrumentation straight into Sentry as one unified per-run trace.
138
- * 3. **Neither**: provide the (no-op) OTLP layer, leaving Effect's native
139
- * in-memory tracer — identical to the prior default behavior.
140
- */
141
- const applyObservability = (program, rootSentrySpan) => {
142
- if (isOtlpExportConfigured()) return (rootSentrySpan ? program.pipe(provideService(ParentSpan, externalSpanFrom(rootSentrySpan))) : program).pipe(provide(layerOtlp));
143
- if (rootSentrySpan) return program.pipe(withTracer(makeSentryTracer(rootSentrySpan)));
144
- return program.pipe(provide(layerOtlp));
145
- };
146
- //#endregion
147
- //#region src/cli/utils/build-runtime-layers.ts
148
- /**
149
- * Adapts the CLI's existing `spinner()` helper (an ora wrapper that
150
- * already handles non-interactive demotion + `setSpinnerSilent`) into
151
- * a `ProgressHandle` factory the orchestrator can drive via the
152
- * `Progress` service.
153
- */
154
- const buildSpinnerProgressHandle = (text) => {
155
- const oraHandle = spinner(text).start();
156
- return {
157
- update: (displayText) => sync(() => oraHandle.update(displayText)),
158
- succeed: (displayText) => sync(() => oraHandle.succeed(displayText)),
159
- fail: (displayText) => sync(() => oraHandle.fail(displayText)),
160
- stop: () => sync(() => oraHandle.stop())
161
- };
162
- };
163
- /**
164
- * Composes the production layer stack for `inspect()`'s
165
- * `Effect.runPromise(Effect.provide(...))` call. Lives outside
166
- * `inspect.ts` so the orchestrator stays focused on Effect program
167
- * construction and post-scan rendering — layer wiring is its own
168
- * concern with its own contract.
169
- *
170
- * Same service shape as `@react-doctor/api → diagnose()`'s
171
- * `buildDiagnoseLayer`, with the differences specific to the CLI path:
172
- *
173
- * - **Config**: when the caller passes `configOverride`, the
174
- * already-loaded config is provided via `Config.layerOf` instead
175
- * of re-loading from disk; `configSourceDirectory` is threaded
176
- * through so `userConfig.plugins` resolution still anchors at
177
- * the original config file location.
178
- * - **Score**: `layerHttp` for normal runs; `layerOf(null)` only when
179
- * the caller passed `--no-score`. The orchestrator applies the
180
- * `"score"` surface filter to the diagnostic set before calling
181
- * `Score.compute`, so the in-band score matches what the public-API
182
- * contract documents.
183
- * - **Progress**: `layerOra` wired to the CLI's existing ora-backed
184
- * spinner helper for terminal feedback; `layerNoop` for silent /
185
- * score-only / lint-skipped runs.
186
- */
187
- const buildRuntimeLayers = (input) => {
188
- const linterLayer = input.shouldSkipLint ? Linter.layerOf([]) : Linter.layerOxlint;
189
- const deadCodeLayer = input.shouldRunDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]);
190
- const scoreLayer = input.shouldComputeScore ? Score.layerHttp : Score.layerOf(null);
191
- const supplyChainLayer = input.shouldRunSupplyChain ? SupplyChain.layerNode : SupplyChain.layerOf([]);
192
- const projectLayer = input.projectInfoOverride === void 0 ? Project.layerNode : Project.layerOf(input.projectInfoOverride);
193
- const progressLayer = input.progressLayer ?? (input.shouldShowProgressSpinners ? Progress.layerOra(buildSpinnerProgressHandle) : Progress.layerNoop);
194
- const reporterLayer = input.reporterLayer ?? Reporter.layerNoop;
195
- const baseLayers = mergeAll(projectLayer, input.hasConfigOverride ? Config.layerOf({
196
- config: input.userConfig,
197
- resolvedDirectory: input.directory,
198
- configSourceDirectory: input.configSourceDirectory
199
- }) : Config.layerNode, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, reporterLayer, scoreLayer, supplyChainLayer);
200
- const layersWithConcurrency = input.oxlintConcurrency === void 0 ? baseLayers : mergeAll(baseLayers, succeed(OxlintConcurrency, input.oxlintConcurrency));
201
- return input.oxlintSpawnSlots === void 0 ? layersWithConcurrency : mergeAll(layersWithConcurrency, succeed(OxlintSpawnSlots, input.oxlintSpawnSlots));
202
- };
203
- //#endregion
204
- //#region src/cli/utils/with-sentry-run-span.ts
205
- /**
206
- * Clears the module-level run-scoped Sentry state — the current scanned project
207
- * and the active run trace. `inspect()` calls this at the start of every run and
208
- * again after a clean one, so a prior or just-finished scan can't attach its
209
- * project tags / trace to a later run or to a non-scan error (e.g.
210
- * inspectAction's post-loop finalize/handoff steps). A thrown scan error skips
211
- * the post-run reset, leaving the state for the command catch to attribute and
212
- * link the crash. Concurrent batch members (`concurrentScan`) never touch this
213
- * state — they neither write nor reset it. Safe to call when Sentry is off (the
214
- * refs are read only when an event is built).
215
- */
216
- const resetSentryRunState = () => {
217
- setSentryProjectInfo(null);
218
- setActiveRunTrace(null);
219
- };
220
- /**
221
- * Runs an inspect invocation inside a Sentry root span (transaction) so each
222
- * `react-doctor` run is a first-class trace with timing and the run snapshot as
223
- * attributes. The span is handed to `run` so the Effect→Sentry tracer bridge
224
- * can parent its spans under it.
225
- *
226
- * A no-op pass-through when Sentry performance tracing is off (Sentry disabled,
227
- * `--no-score`, tests, or `SENTRY_TRACES_SAMPLE_RATE=0`) — `run` receives
228
- * `undefined` and no transaction is created, so there's no added exit latency.
229
- *
230
- * While the span runs, its trace context is recorded as the active run trace so
231
- * `reportErrorToSentry` can attach a crash thrown during the scan back to this
232
- * transaction's trace (errors surface in the command catch, after the span has
233
- * ended). `inspect()` owns clearing it (and the scanned project): it resets the
234
- * state right after a clean run and at the start of the next one, so the trace
235
- * is never attached to a non-scan error; on a thrown error the state is left in
236
- * place for the command catch, then the process exits.
237
- *
238
- * A `concurrentScan` (one member of the CLI's multi-project pool) still gets
239
- * its own root span, but skips recording the active run trace — the module-
240
- * level handle has single-scan semantics, and overlapping writers would link a
241
- * crash to an arbitrary sibling's trace.
242
- */
243
- const withSentryRunSpan = (run, options = {}) => {
244
- if (!isSentryTracingEnabled()) return run(void 0);
245
- const { tags } = buildSentryScope();
246
- const command = typeof tags.command === "string" ? tags.command : "inspect";
247
- return Sentry.startSpan({
248
- name: `react-doctor ${command}`,
249
- op: "cli.inspect",
250
- attributes: toSpanAttributes(tags)
251
- }, (rootSpan) => {
252
- const spanContext = rootSpan.spanContext();
253
- recordRunTraceId(spanContext.traceId);
254
- if (options.concurrentScan !== true) setActiveRunTrace({
255
- traceId: spanContext.traceId,
256
- spanId: spanContext.spanId,
257
- sampled: (spanContext.traceFlags & 1) === 1
258
- });
259
- return run(rootSpan);
260
- });
261
- };
262
- /**
263
- * Records the scanned project (discovered in the `beforeLint` hook) for Sentry:
264
- * remembers it for the lazy error-capture path (`buildSentryScope` folds it into
265
- * exception events) and, when tracing is live, sets it as attributes on the
266
- * run's root span so the transaction/trace carries the project shape too.
267
- * Always cheap — the span attribute set is skipped when `rootSpan` is absent
268
- * (tracing off), and storing the info is a plain assignment.
269
- *
270
- * A `concurrentScan` only sets the span attributes: the module-level project
271
- * ref has single-scan semantics, and overlapping writers would stamp events
272
- * and metrics with an arbitrary sibling's project. Wide events keep full
273
- * attribution (they ride the span); per-emit metrics simply omit the project
274
- * shape during a concurrent batch (absent, never wrong).
275
- */
276
- const recordSentryProjectContext = (projectInfo, rootSpan, options = {}) => {
277
- if (options.concurrentScan !== true) setSentryProjectInfo(projectInfo);
278
- rootSpan?.setAttributes(toSpanAttributes(buildSentryProjectContext(projectInfo).tags));
279
- };
280
- //#endregion
281
- //#region src/cli/utils/diagnostic-grouping.ts
282
- const buildRulePriorityMap = (scores) => {
283
- const rulePriority = /* @__PURE__ */ new Map();
284
- for (const score of scores) {
285
- if (!score?.rules) continue;
286
- for (const [ruleKey, info] of Object.entries(score.rules)) if (typeof info.priority === "number") rulePriority.set(ruleKey, info.priority);
287
- }
288
- return rulePriority;
289
- };
290
- const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
291
- const priorityA = rulePriority?.get(ruleKeyA);
292
- const priorityB = rulePriority?.get(ruleKeyB);
293
- if (priorityA === void 0 && priorityB === void 0) return 0;
294
- if (priorityA === void 0) return 1;
295
- if (priorityB === void 0) return -1;
296
- return priorityB - priorityA;
297
- };
298
- const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
299
- const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
300
- const getSharedFixSiteCount = (diagnostics) => {
301
- if (diagnostics.length < 2) return 0;
302
- const firstFixGroupId = diagnostics[0]?.fixGroupId;
303
- if (!firstFixGroupId) return 0;
304
- return diagnostics.every((diagnostic) => diagnostic.fixGroupId === firstFixGroupId) ? diagnostics.length : 0;
305
- };
306
- const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
307
- const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
308
- const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
309
- const buildRuleBlastRadii = (diagnostics) => buildSortedRuleGroups(diagnostics).map(([ruleKey, ruleDiagnostics]) => ({
310
- ruleKey,
311
- title: ruleDiagnostics[0].title ?? ruleKey,
312
- siteCount: ruleDiagnostics.length,
313
- fileCount: new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath)).size
314
- })).toSorted((left, right) => right.fileCount - left.fileCount);
315
- const findMigrationScaleBuckets = (diagnostics) => buildRuleBlastRadii(diagnostics).filter((bucket) => bucket.fileCount >= 40);
316
- //#endregion
317
- //#region src/cli/utils/has-lint-hard-failure.ts
318
- const hasLintHardFailure = (result) => result.skippedChecks.includes("lint");
319
- //#endregion
320
- //#region src/cli/utils/is-inspect-result-complete.ts
321
- const isInspectResultComplete = (result) => isScanComplete({
322
- analyzedFileCount: result.analyzedFiles?.length,
323
- scannedFileCount: result.scannedFileCount,
324
- skippedCheckCount: result.skippedChecks.length,
325
- skippedCheckReasonCount: Object.keys(result.skippedCheckReasons ?? {}).length
326
- });
327
- //#endregion
328
- //#region src/cli/utils/record-scan-metrics.ts
329
- /**
330
- * Aggregates diagnostics into per-`(rule, severity)` firing counts, reusing the
331
- * canonical `getDiagnosticRuleIdentity` so the `<plugin>/<rule>` key and
332
- * category match every other rule-keyed surface. Pure and exported so the
333
- * bucketing is unit-testable without an active Sentry client (the emit helpers
334
- * no-op under tests). Grouping the emitted `rule.fired` counter by `category`
335
- * or `severity` in Sentry reproduces a coarser breakdown.
336
- */
337
- const summarizeRuleFirings = (diagnostics) => {
338
- const firings = /* @__PURE__ */ new Map();
339
- for (const diagnostic of diagnostics) {
340
- const { ruleKey, category } = getDiagnosticRuleIdentity(diagnostic);
341
- const firingKey = `${ruleKey}\u0000${diagnostic.severity}`;
342
- const existing = firings.get(firingKey);
343
- firings.set(firingKey, existing ? {
344
- ...existing,
345
- count: existing.count + 1
346
- } : {
347
- rule: ruleKey,
348
- plugin: diagnostic.plugin,
349
- category,
350
- severity: diagnostic.severity,
351
- count: 1
352
- });
353
- }
354
- return [...firings.values()];
355
- };
356
- /**
357
- * Enumerates the rules a config turns off entirely — `rules: { x: "off" }`
358
- * entries and the `ignore.rules` list — with keys canonicalized so every
359
- * spelling of one rule (legacy alias, bare short id) groups under one
360
- * `rule` attribute in Sentry. Deliberately scan-independent: an `"off"`
361
- * lint rule is removed from the generated oxlint config and never fires,
362
- * so the per-diagnostic `rule.suppressed` counter can't see it.
363
- */
364
- const summarizeDisabledRules = (userConfig) => {
365
- const disabledRules = /* @__PURE__ */ new Map();
366
- const record = (configuredRuleKey, source) => {
367
- const rule = canonicalizeUserRuleKey(configuredRuleKey);
368
- disabledRules.set(`${rule}\u0000${source}`, {
369
- rule,
370
- source
371
- });
372
- };
373
- for (const [ruleKey, severity] of Object.entries(userConfig?.rules ?? {})) if (severity === "off") record(ruleKey, "rules");
374
- if (Array.isArray(userConfig?.ignore?.rules)) {
375
- for (const ruleKey of userConfig.ignore.rules) if (typeof ruleKey === "string") record(ruleKey, "ignore");
376
- }
377
- return [...disabledRules.values()];
378
- };
379
- /**
380
- * Emits the per-scan engineering + product counters/distributions for one
381
- * `inspect()` run (fires once per project in a workspace scan). Every emission
382
- * is a no-op unless Sentry is live, and the shared run/project attributes ride
383
- * along from the global scope, so call sites only pass scan-specific dimensions.
384
- */
385
- const recordScanMetrics = (input) => {
386
- const { result } = input;
387
- const hasSkippedChecks = result.skippedChecks.length > 0;
388
- recordCount(METRIC.scanCompleted, 1, {
389
- mode: input.mode,
390
- lint: input.lint,
391
- deadCode: input.deadCode,
392
- parallel: input.parallel,
393
- scoreOnly: input.scoreOnly,
394
- didLintFail: input.didLintFail,
395
- didDeadCodeFail: input.didDeadCodeFail,
396
- hasSkippedChecks
397
- });
398
- recordDistribution(METRIC.scanDuration, result.elapsedMilliseconds, {
399
- unit: "millisecond",
400
- attributes: {
401
- mode: input.mode,
402
- parallel: input.parallel,
403
- scoreOnly: input.scoreOnly
404
- }
405
- });
406
- if (result.scanElapsedMilliseconds !== void 0) recordDistribution(METRIC.scanPhaseDuration, result.scanElapsedMilliseconds, {
407
- unit: "millisecond",
408
- attributes: { mode: input.mode }
409
- });
410
- if (result.scannedFileCount !== void 0) recordDistribution(METRIC.scanFiles, result.scannedFileCount, { attributes: { mode: input.mode } });
411
- if (input.workerCount !== void 0) recordDistribution(METRIC.oxlintWorkers, input.workerCount, { attributes: { mode: input.mode } });
412
- for (const firing of summarizeRuleFirings(result.diagnostics)) recordCount(METRIC.ruleFired, firing.count, {
413
- rule: firing.rule,
414
- plugin: firing.plugin,
415
- category: firing.category,
416
- severity: firing.severity
417
- });
418
- for (const disabled of summarizeDisabledRules(input.userConfig)) recordCount(METRIC.ruleDisabled, 1, {
419
- rule: disabled.rule,
420
- source: disabled.source
421
- });
422
- for (const suppression of input.suppressedRuleCounts) recordCount(METRIC.ruleSuppressed, suppression.count, {
423
- rule: suppression.rule,
424
- source: suppression.source
425
- });
426
- if (result.diagnostics.length === 0 && !hasSkippedChecks) recordCount(METRIC.scanClean, 1, { mode: input.mode });
427
- if (result.score) recordDistribution(METRIC.scanScore, result.score.score, { attributes: { mode: input.mode } });
428
- else if (!input.noScore && !input.didLintFail && !input.didDeadCodeFail) recordCount(METRIC.scoreUnavailable, 1, { mode: input.mode });
429
- if (input.baselineDegraded) recordCount(METRIC.baselineDegraded, 1);
430
- if (input.didLintFail) recordCount(METRIC.lintFailed, 1, { reasonKind: input.lintFailureReasonKind });
431
- if (input.didDeadCodeFail) recordCount(METRIC.deadCodeFailed, 1);
432
- for (const check of result.skippedChecks) recordCount(METRIC.scanCheckSkipped, 1, {
433
- check,
434
- reason: result.skippedCheckReasons?.[check] ?? null
435
- });
436
- };
437
- //#endregion
438
- //#region src/cli/utils/resolve-score-unavailable-reason.ts
439
- const resolveScoreUnavailableReason = (input) => {
440
- if (input.isScoreDisabled) return "disabled";
441
- return input.isAnalysisIncomplete ? "analysis-incomplete" : "api-unavailable";
442
- };
443
- //#endregion
444
- //#region src/cli/utils/scan-result-cache-payload.ts
445
- const decodeDiagnostic = decodeUnknownSync(Diagnostic);
446
- const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
447
- const isNullableString = (value) => value === null || typeof value === "string";
448
- const isBoolean = (value) => typeof value === "boolean";
449
- const isNumber = (value) => typeof value === "number";
450
- const isString = (value) => typeof value === "string";
451
- const isDiagnosticArray = (value) => {
452
- if (!Array.isArray(value)) return false;
453
- try {
454
- for (const entry of value) decodeDiagnostic(entry);
455
- return true;
456
- } catch {
457
- return false;
458
- }
459
- };
460
- const isScoreResult = (value) => value === null || isRecord(value) && typeof value.score === "number" && typeof value.label === "string";
461
- const isProjectInfo = (value) => isRecord(value) && typeof value.rootDirectory === "string" && typeof value.projectName === "string" && typeof value.framework === "string" && typeof value.sourceFileCount === "number";
462
- const isBaselineDelta = (value) => isRecord(value) && typeof value.baseRef === "string" && typeof value.fixedCount === "number" && typeof value.baseTotalCount === "number" && (value.crossFileMatchCount === void 0 || typeof value.crossFileMatchCount === "number");
463
- const isSuppressedRuleCountArray = (value) => Array.isArray(value) && value.every((entry) => isRecord(entry) && typeof entry.rule === "string" && typeof entry.source === "string" && typeof entry.count === "number");
464
- const REQUIRED_CACHED_SCAN_PAYLOAD_FIELDS = [
465
- {
466
- key: "diagnostics",
467
- isValid: isDiagnosticArray
468
- },
469
- {
470
- key: "score",
471
- isValid: isScoreResult
472
- },
473
- {
474
- key: "project",
475
- isValid: isProjectInfo
476
- },
477
- {
478
- key: "userConfig",
479
- isValid: (value) => value === null || isRecord(value)
480
- },
481
- {
482
- key: "didLintFail",
483
- isValid: isBoolean
484
- },
485
- {
486
- key: "lintFailureReason",
487
- isValid: isNullableString
488
- },
489
- {
490
- key: "lintPartialFailures",
491
- isValid: isStringArray
492
- },
493
- {
494
- key: "didDeadCodeFail",
495
- isValid: isBoolean
496
- },
497
- {
498
- key: "deadCodeFailureReason",
499
- isValid: isNullableString
500
- },
501
- {
502
- key: "deadCodeOverlapped",
503
- isValid: isBoolean
504
- },
505
- {
506
- key: "directory",
507
- isValid: isString
508
- },
509
- {
510
- key: "scannedFileCount",
511
- isValid: isNumber
512
- },
513
- {
514
- key: "scannedFilePaths",
515
- isValid: isStringArray
516
- },
517
- {
518
- key: "scanElapsedMilliseconds",
519
- isValid: isNumber
520
- },
521
- {
522
- key: "lintFailureReasonKind",
523
- isValid: isNullableString
524
- },
525
- {
526
- key: "supplyChainOverlapTimedOut",
527
- isValid: isBoolean
528
- }
529
- ];
530
- const OPTIONAL_CACHED_SCAN_PAYLOAD_FIELDS = [
531
- {
532
- key: "analyzedFiles",
533
- isValid: isStringArray
534
- },
535
- {
536
- key: "baselineDelta",
537
- isValid: isBaselineDelta
538
- },
539
- {
540
- key: "scanConcurrency",
541
- isValid: isNumber
542
- },
543
- {
544
- key: "securityScanFailed",
545
- isValid: isBoolean
546
- },
547
- {
548
- key: "securityScanFailureReason",
549
- isValid: isNullableString
550
- },
551
- {
552
- key: "suppressedRuleCounts",
553
- isValid: isSuppressedRuleCountArray
554
- },
555
- {
556
- key: "manifestContentHash",
557
- isValid: isNullableString
558
- }
559
- ];
560
- const hasValidRequiredCachedScanPayloadFields = (value) => REQUIRED_CACHED_SCAN_PAYLOAD_FIELDS.every((field) => field.isValid(value[field.key]));
561
- const hasValidOptionalCachedScanPayloadFields = (value) => OPTIONAL_CACHED_SCAN_PAYLOAD_FIELDS.every((field) => value[field.key] === void 0 || field.isValid(value[field.key]));
562
- const isCachedScanPayload = (value) => {
563
- if (!isRecord(value)) return false;
564
- return hasValidRequiredCachedScanPayloadFields(value) && hasValidOptionalCachedScanPayloadFields(value);
565
- };
566
- const decodeCachedScanPayload = (value) => isCachedScanPayload(value) ? value : null;
567
- const shouldStoreScanPayload = (payload) => !payload.didLintFail && !payload.didDeadCodeFail && payload.lintPartialFailures.length === 0 && !payload.supplyChainOverlapTimedOut && payload.securityScanFailed !== true;
568
- //#endregion
569
- //#region src/cli/utils/scan-result-cache.ts
570
- const createScanResultCacheInvocationState = () => ({ repositoryIdentityByRoot: /* @__PURE__ */ new Map() });
571
- const CACHE_DISABLED_VALUES = new Set(["1", "true"]);
572
- const TOOLCHAIN_PACKAGE_SPECIFIERS = [
573
- "oxlint/package.json",
574
- "oxlint-plugin-react-doctor/package.json",
575
- "deslop-js/package.json",
576
- "eslint-plugin-react-hooks/package.json"
577
- ];
578
- const bundledRequire = createRequire(import.meta.url);
579
- const normalizeForStableJson = (value) => {
580
- if (value === null) return null;
581
- if (value === void 0) return void 0;
582
- const valueType = typeof value;
583
- if (valueType === "string" || valueType === "number" || valueType === "boolean") return value;
584
- if (valueType === "bigint" || valueType === "function" || valueType === "symbol") throw new Error("Unsupported cache key value");
585
- if (Array.isArray(value)) return value.map((item) => {
586
- const normalized = normalizeForStableJson(item);
587
- return normalized === void 0 ? null : normalized;
588
- });
589
- const prototype = Object.getPrototypeOf(value);
590
- if (prototype !== Object.prototype && prototype !== null) throw new Error("Unsupported cache key object");
591
- if (!isRecord(value)) throw new Error("Unsupported cache key object");
592
- const normalizedRecord = {};
593
- for (const key of Object.keys(value).sort()) {
594
- const normalized = normalizeForStableJson(value[key]);
595
- if (normalized !== void 0) normalizedRecord[key] = normalized;
596
- }
597
- return normalizedRecord;
598
- };
599
- const stringifyStableJson = (value) => {
600
- try {
601
- return JSON.stringify(normalizeForStableJson(value));
602
- } catch {
603
- return null;
604
- }
605
- };
606
- const hashString = (value) => crypto.createHash("sha1").update(value).digest("hex");
607
- const readHeadSha = (projectDirectory) => runGit(projectDirectory, ["rev-parse", "HEAD"]);
608
- const parseWorktreeStatusRecords = (statusOutput) => {
609
- const entries = [];
610
- const tokens = statusOutput.split("\0");
611
- for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) {
612
- let record = tokens[tokenIndex];
613
- if (record.length === 0) continue;
614
- if (record.length < 3 || record[2] !== " ") {
615
- if (tokenIndex !== 0) return null;
616
- record = ` ${record}`;
617
- }
618
- if (record.length < 4 || record[2] !== " ") return null;
619
- const statusCode = record.slice(0, 2);
620
- const recordPath = record.slice(3);
621
- if (statusCode.includes("R") || statusCode.includes("C")) {
622
- tokenIndex += 1;
623
- const originPath = tokens[tokenIndex];
624
- if (originPath === void 0 || originPath.length === 0) return null;
625
- entries.push({
626
- statusCode,
627
- path: recordPath,
628
- originPath
629
- });
630
- continue;
631
- }
632
- entries.push({
633
- statusCode,
634
- path: recordPath
635
- });
636
- }
637
- return entries;
638
- };
639
- const buildDirtyPathContentFingerprint = (absolutePath, statusCode) => {
640
- let stats;
641
- try {
642
- stats = fs.lstatSync(absolutePath);
643
- } catch {
644
- return statusCode.includes("D") ? "deleted" : null;
645
- }
646
- if (stats.isSymbolicLink()) try {
647
- return `link:${fs.readlinkSync(absolutePath)}`;
648
- } catch {
649
- return null;
650
- }
651
- if (!stats.isFile()) return null;
652
- if (stats.size > 10485760) return null;
653
- return hashFileContents(absolutePath);
654
- };
655
- const buildWorktreeFingerprint = (projectDirectory, repositoryRoot) => {
656
- const statusOutput = runGit(projectDirectory, [
657
- "status",
658
- "--porcelain=v1",
659
- "-z",
660
- "--untracked-files=all"
661
- ]);
662
- if (statusOutput === null) return null;
663
- if (statusOutput.length === 0) return [];
664
- const statusEntries = parseWorktreeStatusRecords(statusOutput);
665
- if (statusEntries === null) return null;
666
- if (statusEntries.length > 300) return null;
667
- const dirtyEntries = [];
668
- for (const statusEntry of statusEntries) {
669
- const contentFingerprint = buildDirtyPathContentFingerprint(path.join(repositoryRoot, statusEntry.path), statusEntry.statusCode);
670
- if (contentFingerprint === null) return null;
671
- dirtyEntries.push({
672
- ...statusEntry,
673
- contentFingerprint
674
- });
675
- }
676
- return dirtyEntries.sort((firstEntry, secondEntry) => {
677
- const firstSortKey = `${firstEntry.path}${firstEntry.statusCode}`;
678
- const secondSortKey = `${secondEntry.path}${secondEntry.statusCode}`;
679
- return firstSortKey < secondSortKey ? -1 : firstSortKey > secondSortKey ? 1 : 0;
680
- });
681
- };
682
- const resolveRepositoryCacheIdentity = (projectDirectory, invocationState) => {
683
- const repositoryRoot = runGit(projectDirectory, ["rev-parse", "--show-toplevel"]);
684
- if (repositoryRoot === null) return null;
685
- const cachedIdentity = invocationState.repositoryIdentityByRoot.get(repositoryRoot);
686
- if (cachedIdentity !== void 0) return cachedIdentity;
687
- const worktreeFingerprint = buildWorktreeFingerprint(projectDirectory, repositoryRoot);
688
- const headSha = readHeadSha(repositoryRoot);
689
- const identity = worktreeFingerprint === null || headSha === null ? null : {
690
- headSha,
691
- worktreeFingerprint
692
- };
693
- invocationState.repositoryIdentityByRoot.set(repositoryRoot, identity);
694
- return identity;
695
- };
696
- const DOTENV_FILE_NAME_PATTERN = /^\.env(\.|$)/;
697
- const resolveDotenvFingerprint = (projectDirectory) => {
698
- try {
699
- return fs.readdirSync(projectDirectory).filter((entryName) => DOTENV_FILE_NAME_PATTERN.test(entryName)).sort().map((entryName) => `${entryName}=${hashFileContents(path.join(projectDirectory, entryName)) ?? "unreadable"}`);
700
- } catch {
701
- return [];
702
- }
703
- };
704
- const isGitIdentityTrustworthy = (projectDirectory) => {
705
- const output = runGit(projectDirectory, ["ls-files", "-v"]);
706
- if (output === null) return false;
707
- const entryLines = output.split("\n").filter((line) => line.length > 0);
708
- return entryLines.length > 0 && entryLines.every((line) => line[0] === "H");
709
- };
710
- const resolveCacheFilePath = (projectDirectory) => path.join(resolveReactDoctorCacheDir(projectDirectory), SCAN_RESULT_CACHE_FILENAME);
711
- const readPersistedCache = (cacheFilePath) => {
712
- try {
713
- const parsed = JSON.parse(fs.readFileSync(cacheFilePath, "utf8"));
714
- if (!isRecord(parsed) || parsed.version !== 6) return {
715
- version: 6,
716
- entries: []
717
- };
718
- if (!Array.isArray(parsed.entries)) return {
719
- version: 6,
720
- entries: []
721
- };
722
- const entries = [];
723
- for (const entry of parsed.entries) {
724
- if (!isRecord(entry) || typeof entry.key !== "string" || typeof entry.createdAtMs !== "number") continue;
725
- const payload = decodeCachedScanPayload(entry.payload);
726
- if (payload === null) continue;
727
- entries.push({
728
- key: entry.key,
729
- createdAtMs: entry.createdAtMs,
730
- payload
731
- });
732
- }
733
- return {
734
- version: 6,
735
- entries
736
- };
737
- } catch {
738
- return {
739
- version: 6,
740
- entries: []
741
- };
742
- }
743
- };
744
- /**
745
- * The global cache off-switch (`REACT_DOCTOR_NO_CACHE`), which disables every
746
- * cache subsystem: this whole-repo scan cache plus core's per-file lint,
747
- * sidecar, and dead-code caches (their `Context.Reference` defaults read the
748
- * same variable). Exported for the wide event's `cache.temperature`
749
- * derivation, which reports `"disabled"` instead of `"cold"` when the switch
750
- * is on. Granular knobs (`REACT_DOCTOR_NO_FILE_CACHE`, …) are deliberately
751
- * not consulted — they leave the other subsystems live.
752
- */
753
- const isCacheGloballyDisabled = () => CACHE_DISABLED_VALUES.has(process.env.REACT_DOCTOR_NO_CACHE?.toLowerCase() ?? "");
754
- const resolveProjectIdentity = (projectDirectory) => {
755
- try {
756
- return fs.realpathSync.native(projectDirectory);
757
- } catch {
758
- return path.resolve(projectDirectory);
759
- }
760
- };
761
- const resolveManifestContentHash = (projectDirectory) => hashFileContents(getPackageJsonPath(projectDirectory));
762
- const fileFingerprint = (filePath) => {
763
- try {
764
- const stat = fs.statSync(filePath);
765
- return `${filePath}:${stat.mtimeMs}:${stat.size}`;
766
- } catch {
767
- return null;
768
- }
769
- };
770
- const resolveToolchainFingerprint = (nodeBinaryPath) => {
771
- const fingerprints = [];
772
- if (nodeBinaryPath !== null) fingerprints.push(nodeBinaryPath === process.execPath ? `node=${process.version}` : `node=${fileFingerprint(nodeBinaryPath) ?? "unreadable"}`);
773
- for (const specifier of TOOLCHAIN_PACKAGE_SPECIFIERS) try {
774
- const packageJson = bundledRequire(specifier);
775
- const version = typeof packageJson.version === "string" ? packageJson.version : "unknown";
776
- fingerprints.push(`${specifier}=${version}`);
777
- } catch {
778
- fingerprints.push(`${specifier}=missing`);
779
- }
780
- return fingerprints;
781
- };
782
- const buildScanResultCacheKey = (input) => {
783
- if (isCacheGloballyDisabled()) return null;
784
- if (!isGitIdentityTrustworthy(input.projectDirectory)) return null;
785
- const repositoryIdentity = resolveRepositoryCacheIdentity(input.projectDirectory, input.invocationState);
786
- if (repositoryIdentity === null) return null;
787
- if (stringifyStableJson(input.userConfig) === null) return null;
788
- const cacheKeyJson = stringifyStableJson({
789
- schemaVersion: 6,
790
- projectIdentity: resolveProjectIdentity(input.projectDirectory),
791
- headSha: repositoryIdentity.headSha,
792
- worktreeFingerprint: repositoryIdentity.worktreeFingerprint,
793
- dotenvFingerprint: resolveDotenvFingerprint(input.projectDirectory),
794
- reactDoctorVersion: input.version,
795
- nodeVersion: process.version,
796
- toolchainFingerprint: resolveToolchainFingerprint(input.nodeBinaryPath),
797
- configFingerprint: computeConfigFingerprint(input.projectDirectory, input.version),
798
- hasConfigOverride: input.hasConfigOverride,
799
- configSourceDirectory: input.configSourceDirectory,
800
- userConfig: input.userConfig,
801
- engineOptions: {
802
- lint: input.options.lint,
803
- deadCode: input.options.deadCode,
804
- supplyChain: input.options.supplyChain,
805
- includePaths: [...input.options.includePaths].sort(),
806
- excludedProjectDirectories: [...input.options.excludedProjectDirectories ?? []].sort(),
807
- retainExcludedProjectDeadCodeDiagnostics: input.options.retainExcludedProjectDeadCodeDiagnostics ?? false,
808
- customRulesOnly: input.options.customRulesOnly,
809
- respectInlineDisables: input.options.respectInlineDisables,
810
- warnings: input.options.warnings,
811
- adoptExistingLintConfig: input.options.adoptExistingLintConfig,
812
- ignoredTags: [...input.options.ignoredTags].sort(),
813
- includedTags: [...input.options.includedTags].sort(),
814
- includeTagDefaults: input.options.includeTagDefaults,
815
- concurrency: input.options.concurrency,
816
- lintBatchOrdering: resolveLintBatchOrdering(),
817
- baselineRef: input.options.baseline?.ref,
818
- changedLineRanges: input.options.changedLineRanges ?? void 0,
819
- noScore: input.options.noScore,
820
- isCi: input.options.isCi,
821
- suppressRendering: input.options.suppressRendering,
822
- supplyChainManifestChanged: input.options.supplyChainManifestChanged
823
- }
824
- });
825
- return cacheKeyJson === null ? null : hashString(cacheKeyJson);
826
- };
827
- const createScanResultCache = (projectDirectory) => {
828
- const cacheFilePath = resolveCacheFilePath(projectDirectory);
829
- const persistedCache = readPersistedCache(cacheFilePath);
830
- const entries = /* @__PURE__ */ new Map();
831
- for (const entry of persistedCache.entries) entries.set(entry.key, entry);
832
- const persist = () => {
833
- const mergedEntries = /* @__PURE__ */ new Map();
834
- for (const entry of readPersistedCache(cacheFilePath).entries) mergedEntries.set(entry.key, entry);
835
- for (const entry of entries.values()) mergedEntries.set(entry.key, entry);
836
- atomicWriteJson(cacheFilePath, {
837
- version: 6,
838
- entries: [...mergedEntries.values()].sort((firstEntry, secondEntry) => secondEntry.createdAtMs - firstEntry.createdAtMs).slice(0, 20)
839
- });
840
- };
841
- return {
842
- lookup: (key) => {
843
- const payload = entries.get(key)?.payload;
844
- if (payload === void 0) return null;
845
- return resolveProjectIdentity(payload.directory) === resolveProjectIdentity(projectDirectory) && (payload.manifestContentHash ?? null) === resolveManifestContentHash(projectDirectory) ? payload : null;
846
- },
847
- store: (key, payload) => {
848
- entries.set(key, {
849
- key,
850
- createdAtMs: Date.now(),
851
- payload: {
852
- ...payload,
853
- manifestContentHash: resolveManifestContentHash(projectDirectory)
854
- }
855
- });
856
- persist();
857
- }
858
- };
859
- };
860
- //#endregion
861
- //#region src/cli/utils/should-block-ci.ts
862
- const shouldBlockCi = (diagnostics, blockingLevel) => {
863
- if (blockingLevel === "none") return false;
864
- if (blockingLevel === "warning") return diagnostics.length > 0;
865
- return diagnostics.some((diagnostic) => diagnostic.severity === "error");
866
- };
867
- //#endregion
868
- //#region src/cli/utils/to-category-key.ts
869
- /**
870
- * Lowercase, key-safe form of a rule category for the `diag.category.*`
871
- * telemetry attribute namespace (categories carry spaces / capitals, e.g.
872
- * "Performance" → `performance`, "Dead Code" → `dead_code`). Shared by the
873
- * CLI run event and the editor LSP wide event so both namespace identically.
874
- */
875
- const toCategoryKey = (category) => category.toLowerCase().replace(/[^a-z0-9]+/g, "_");
876
- //#endregion
877
- //#region src/cli/utils/with-namespace.ts
878
- /**
879
- * Prefixes every key in a flat attribute group with `namespace.`, so a logical
880
- * group (scan config, diagnostics rollup, score, …) carries one dotted
881
- * namespace applied in a single place rather than hand-spelled into each key —
882
- * which is how the wide event drifted into a flat, half-namespaced soup. The
883
- * dotted prefix is what makes the attributes tree up in Sentry's attribute
884
- * browser and stay group-/filter-/aggregate-able in the Spans dataset.
885
- *
886
- * Value types are preserved verbatim (numbers stay numbers so Sentry infers a
887
- * numeric attribute and `p75(...)`/`avg(...)` work; `null`/`undefined` are kept
888
- * so `toSpanAttributes` can drop absent signals rather than coerce them).
889
- */
890
- const withNamespace = (namespace, attributes) => {
891
- const namespaced = {};
892
- for (const [key, value] of Object.entries(attributes)) namespaced[`${namespace}.${key}`] = value;
893
- return namespaced;
894
- };
895
- //#endregion
896
- //#region src/cli/utils/build-run-event.ts
897
- const readEnvBoolean = (name) => {
898
- const value = process.env[name];
899
- if (value === void 0) return null;
900
- return value.toLowerCase() === "true" || value === "1";
901
- };
902
- const ratioOf = (numerator, denominator) => denominator != null && denominator > 0 ? (numerator ?? 0) / denominator : null;
903
- const resolveDeadCodeReuseRatio = (result) => {
904
- if (result.deadCodeCacheHit === true) return 1;
905
- const summaryTotal = (result.deadCodeSummaryCacheHits ?? 0) + (result.deadCodeSummaryCacheMisses ?? 0);
906
- if (summaryTotal > 0) return (result.deadCodeSummaryCacheHits ?? 0) / summaryTotal;
907
- return result.deadCodeCacheHit === false ? 0 : null;
908
- };
909
- /**
910
- * One queryable cache temperature per scan, derived from the whole stack:
911
- *
912
- * - `"turbo"` — whole-repo scan-result replay (the explicit
913
- * `wholeRepoCacheHit` flag from the CLI's cachedPayload
914
- * branch; no scan work ran).
915
- * - `"warm"` — any incremental reuse: per-file lint hits, sidecar
916
- * replays, a dead-code whole-result hit, or dead-code
917
- * summary-cache hits.
918
- * - `"disabled"` — zero reuse because the global `REACT_DOCTOR_NO_CACHE`
919
- * off-switch is on. Granular per-cache opt-outs
920
- * (`REACT_DOCTOR_NO_FILE_CACHE`, …) still read warm/cold,
921
- * since the other subsystems stay live.
922
- * - `"cold"` — caches on, zero reuse (first scan / everything changed).
923
- *
924
- * `cache.warmth` is the headline reuse magnitude in [0, 1]: the plain mean of
925
- * the subsystem reuse fractions known this run (lint hit ratio, sidecar
926
- * replay ratio, dead-code reuse), skipping subsystems that never consulted a
927
- * cache; `1` on turbo, dropped when nothing consulted any cache. Deliberately
928
- * unweighted — the per-subsystem dims stay the precise signal; warmth is the
929
- * p50/p90-able summary. Emitted only on the success path.
930
- */
931
- const buildCacheAttributes = (input) => {
932
- if (input.result === void 0) return {};
933
- if (input.wholeRepoCacheHit) return withNamespace("cache", {
934
- wholeRepoHit: true,
935
- temperature: "turbo",
936
- warmth: 1
937
- });
938
- const result = input.result;
939
- const subsystemReuseRatios = [
940
- ratioOf(result.lintCacheHitFileCount, result.lintCacheTotalFileCount),
941
- ratioOf(result.lintSidecarReplayedFileCount, result.lintSidecarTotalFileCount),
942
- resolveDeadCodeReuseRatio(result)
943
- ];
944
- let knownSubsystemCount = 0;
945
- let reuseRatioSum = 0;
946
- for (const reuseRatio of subsystemReuseRatios) {
947
- if (reuseRatio === null) continue;
948
- knownSubsystemCount += 1;
949
- reuseRatioSum += reuseRatio;
950
- }
951
- const warmth = knownSubsystemCount > 0 ? reuseRatioSum / knownSubsystemCount : null;
952
- const temperature = warmth !== null && warmth > 0 ? "warm" : isCacheGloballyDisabled() ? "disabled" : "cold";
953
- return withNamespace("cache", {
954
- wholeRepoHit: input.wholeRepoCacheHit,
955
- temperature,
956
- warmth
957
- });
958
- };
959
- const resolveVersionPin = (versionInput) => {
960
- if (versionInput === void 0 || versionInput.trim() === "") return null;
961
- if (versionInput === "latest") return "latest";
962
- if (/^(\.\.?\/|\/)/.test(versionInput)) return "local";
963
- return "pinned";
964
- };
965
- const resolveTelemetryBlocking = (userConfig) => {
966
- const fromAction = process.env[ACTION_INPUT_ENVIRONMENT_VARIABLES.blocking];
967
- if (fromAction !== void 0 && isValidBlockingLevel(fromAction)) return fromAction;
968
- return userConfig?.blocking ?? userConfig?.failOn ?? "error";
969
- };
970
- const buildOutcomeAttributes = (input) => {
971
- if (input.result === void 0) {
972
- const error = input.error;
973
- const known = isReactDoctorError(error);
974
- return withNamespace("outcome", {
975
- status: "error",
976
- exitCode: 1,
977
- complete: false,
978
- knownError: known,
979
- errorTag: known ? error.reason._tag : error instanceof Error ? error.name : null
980
- });
981
- }
982
- const result = input.result;
983
- const summary = summarizeDiagnostics(result.diagnostics);
984
- const blockingLevel = resolveTelemetryBlocking(input.userConfig);
985
- const gateDiagnostics = filterDiagnosticsForSurface(result.diagnostics, "ciFailure", input.userConfig);
986
- const gateDiagnosticSet = new Set(gateDiagnostics);
987
- const complete = isInspectResultComplete(result);
988
- const didLintHardFail = hasLintHardFailure(result);
989
- const failsOnHardFailure = didLintHardFail && blockingLevel !== "none";
990
- const wouldBlock = !input.scoreOnly && !input.gateExempt && shouldBlockCi(gateDiagnostics, blockingLevel);
991
- const isClean = result.diagnostics.length === 0 && complete;
992
- const outcome = didLintHardFail ? "error" : wouldBlock ? "blocked" : isClean ? "clean" : "ok";
993
- const firings = summarizeRuleFirings(result.diagnostics);
994
- const countByRule = /* @__PURE__ */ new Map();
995
- const countByCategory = /* @__PURE__ */ new Map();
996
- for (const firing of firings) {
997
- countByRule.set(firing.rule, (countByRule.get(firing.rule) ?? 0) + firing.count);
998
- countByCategory.set(firing.category, (countByCategory.get(firing.category) ?? 0) + firing.count);
999
- }
1000
- let topRule = null;
1001
- let topRuleCount = 0;
1002
- for (const [rule, count] of countByRule) if (count > topRuleCount) {
1003
- topRule = rule;
1004
- topRuleCount = count;
1005
- }
1006
- const largestRuleBucket = buildRuleBlastRadii(result.diagnostics)[0] ?? null;
1007
- let diagnosticsInTestFiles = 0;
1008
- let diagnosticsInStoryFiles = 0;
1009
- let nonProductionGateExcluded = 0;
1010
- const diagnosticSiteCounts = /* @__PURE__ */ new Map();
1011
- const findingsPerFixGroup = /* @__PURE__ */ new Map();
1012
- for (const diagnostic of result.diagnostics) {
1013
- if (diagnostic.fileContext === "test") diagnosticsInTestFiles += 1;
1014
- if (diagnostic.fileContext === "story") diagnosticsInStoryFiles += 1;
1015
- if (diagnostic.fileContext !== void 0 && !gateDiagnosticSet.has(diagnostic)) nonProductionGateExcluded += 1;
1016
- const diagnosticSite = JSON.stringify([
1017
- diagnostic.filePath,
1018
- diagnostic.line,
1019
- diagnostic.column,
1020
- diagnostic.plugin,
1021
- diagnostic.rule
1022
- ]);
1023
- diagnosticSiteCounts.set(diagnosticSite, (diagnosticSiteCounts.get(diagnosticSite) ?? 0) + 1);
1024
- if (diagnostic.fixGroupId) findingsPerFixGroup.set(diagnostic.fixGroupId, (findingsPerFixGroup.get(diagnostic.fixGroupId) ?? 0) + 1);
1025
- }
1026
- let fixGroupedFindings = 0;
1027
- for (const count of findingsPerFixGroup.values()) fixGroupedFindings += count;
1028
- let sameSiteOccurrences = 0;
1029
- for (const count of diagnosticSiteCounts.values()) sameSiteOccurrences += Math.max(0, count - 1);
1030
- const categoryRollup = {};
1031
- for (const [category, count] of countByCategory) categoryRollup[`category.${toCategoryKey(category)}`] = count;
1032
- const suppressionRollup = {};
1033
- if (input.suppressedRuleCounts) {
1034
- const countBySource = {
1035
- config: 0,
1036
- override: 0,
1037
- inline: 0,
1038
- "foreign-inline": 0
1039
- };
1040
- for (const suppression of input.suppressedRuleCounts) countBySource[suppression.source] += suppression.count;
1041
- suppressionRollup.suppressed = countBySource.config + countBySource.override + countBySource.inline + countBySource["foreign-inline"];
1042
- suppressionRollup.suppressedConfig = countBySource.config;
1043
- suppressionRollup.suppressedOverride = countBySource.override;
1044
- suppressionRollup.suppressedInline = countBySource.inline;
1045
- suppressionRollup.suppressedForeignInline = countBySource["foreign-inline"];
1046
- }
1047
- const attributes = {
1048
- ...withNamespace("outcome", {
1049
- status: outcome,
1050
- exitCode: failsOnHardFailure || wouldBlock ? 1 : 0,
1051
- wouldBlock,
1052
- blocking: blockingLevel,
1053
- clean: isClean,
1054
- complete,
1055
- skippedChecks: result.skippedChecks.length
1056
- }),
1057
- ...withNamespace("diag", {
1058
- total: summary.totalDiagnosticCount,
1059
- errors: summary.errorCount,
1060
- warnings: summary.warningCount,
1061
- affectedFiles: summary.affectedFileCount,
1062
- inTestFiles: diagnosticsInTestFiles,
1063
- inStoryFiles: diagnosticsInStoryFiles,
1064
- nonProductionGateExcluded,
1065
- distinctRules: countByRule.size,
1066
- topRule,
1067
- sameSiteOccurrences,
1068
- fixGroups: findingsPerFixGroup.size,
1069
- fixGroupedFindings,
1070
- ...categoryRollup,
1071
- ...suppressionRollup
1072
- }),
1073
- ...withNamespace("score", {
1074
- value: result.score ? result.score.score : null,
1075
- label: result.score ? result.score.label : null,
1076
- available: result.score !== null,
1077
- unavailableReason: result.score === null ? resolveScoreUnavailableReason({
1078
- isScoreDisabled: input.noScore,
1079
- isAnalysisIncomplete: input.didLintFail === true || input.didDeadCodeFail === true
1080
- }) : null
1081
- }),
1082
- ...withNamespace("lint", {
1083
- failed: input.didLintFail,
1084
- failureReasonKind: input.lintFailureReasonKind,
1085
- partialFailureCount: input.lintPartialFailureCount,
1086
- droppedFileCount: input.lintDroppedFileCount,
1087
- deadlineSkippedFileCount: input.lintDeadlineSkippedFileCount,
1088
- cacheHitFiles: result.lintCacheHitFileCount,
1089
- cacheTotalFiles: result.lintCacheTotalFileCount,
1090
- cacheHitRatio: ratioOf(result.lintCacheHitFileCount, result.lintCacheTotalFileCount),
1091
- sidecarReplayedFiles: result.lintSidecarReplayedFileCount,
1092
- sidecarTotalFiles: result.lintSidecarTotalFileCount,
1093
- sidecarReplayRatio: ratioOf(result.lintSidecarReplayedFileCount, result.lintSidecarTotalFileCount)
1094
- }),
1095
- ...withNamespace("deadCode", {
1096
- failed: input.didDeadCodeFail,
1097
- overlapped: input.deadCodeOverlapped,
1098
- cacheHit: result.deadCodeCacheHit,
1099
- summaryCacheHits: result.deadCodeSummaryCacheHits,
1100
- summaryCacheMisses: result.deadCodeSummaryCacheMisses
1101
- }),
1102
- ...withNamespace("supplyChain", { overlapTimedOut: input.supplyChainOverlapTimedOut }),
1103
- ...withNamespace("securityScan", { failed: input.securityScanFailed }),
1104
- ...withNamespace("timing", {
1105
- elapsedMs: result.elapsedMilliseconds,
1106
- scanMs: result.scanElapsedMilliseconds
1107
- }),
1108
- ...withNamespace("migration", {
1109
- largestRuleBucketFiles: largestRuleBucket ? largestRuleBucket.fileCount : null,
1110
- largestRuleBucketSites: largestRuleBucket ? largestRuleBucket.siteCount : null,
1111
- largestRuleBucketRule: largestRuleBucket ? largestRuleBucket.ruleKey : null
1112
- })
1113
- };
1114
- if (result.baselineDelta) Object.assign(attributes, withNamespace("baseline", {
1115
- new: summary.totalDiagnosticCount,
1116
- fixed: result.baselineDelta.fixedCount,
1117
- baseTotal: result.baselineDelta.baseTotalCount,
1118
- crossFileMatches: result.baselineDelta.crossFileMatchCount,
1119
- degraded: false
1120
- }));
1121
- else if (input.gateExempt) attributes["baseline.degraded"] = true;
1122
- return attributes;
1123
- };
1124
- const buildActionAttributes = () => {
1125
- const { githubActorAssociation } = resolveGithubActionsScoreMetadata();
1126
- return withNamespace("action", {
1127
- actorAssociation: githubActorAssociation,
1128
- runnerOs: detectRunnerOs(),
1129
- comment: readEnvBoolean(ACTION_INPUT_ENVIRONMENT_VARIABLES.comment),
1130
- reviewComments: readEnvBoolean(ACTION_INPUT_ENVIRONMENT_VARIABLES.reviewComments),
1131
- versionPin: resolveVersionPin(process.env[ACTION_INPUT_ENVIRONMENT_VARIABLES.version])
1132
- });
1133
- };
1134
- const buildScanAttributes = (input) => {
1135
- const ruleOverrides = input.userConfig?.rules ?? {};
1136
- const ruleKeys = Object.keys(ruleOverrides);
1137
- return withNamespace("scan", {
1138
- mode: input.mode,
1139
- scope: input.scope,
1140
- parallel: input.parallel,
1141
- workerCount: input.workerCount,
1142
- maxDurationMs: input.maxDurationMs,
1143
- lint: input.lint,
1144
- deadCode: input.deadCode,
1145
- supplyChain: input.supplyChain,
1146
- scoreOnly: input.scoreOnly,
1147
- noScore: input.noScore,
1148
- respectInlineDisables: input.respectInlineDisables,
1149
- showWarnings: input.showWarnings,
1150
- usedOutputDir: input.usedOutputDir,
1151
- ignoredTagCount: input.ignoredTagCount,
1152
- hasCustomConfig: input.hasCustomConfig,
1153
- rulesConfigured: ruleKeys.length,
1154
- rulesDisabled: ruleKeys.filter((key) => ruleOverrides[key] === "off").length,
1155
- fileCount: input.result?.scannedFileCount,
1156
- nonJsxFileCount: input.result?.analyzedFiles?.filter((filePath) => !JSX_FILE_PATTERN.test(filePath)).length,
1157
- htmlFileCount: input.result?.analyzedFiles?.filter((filePath) => HTML_FILE_PATTERN.test(filePath)).length,
1158
- multilineDiagnosticCount: input.result?.diagnostics.filter((diagnostic) => (diagnostic.endLine ?? diagnostic.line) > diagnostic.line).length
1159
- });
1160
- };
1161
- /**
1162
- * Projects a scan into the namespaced attribute set for its root span — the
1163
- * canonical per-scan "wide event". Every attribute carries a dotted namespace
1164
- * that groups it by concept (`scan.*` config, `action.*` CI knobs, `outcome.*`
1165
- * verdict, `diag.*` findings, `score.*`, `lint.*`, `deadCode.*`, `cache.*`,
1166
- * `supplyChain.*`, `timing.*`, `migration.*`, `baseline.*`) so the attributes
1167
- * tree up in Sentry's attribute browser and stay filter-/group-/aggregate-able
1168
- * in the Spans dataset. Pure and exported so the projection (outcome
1169
- * precedence, rule/category rollups, CI knobs, config shape) is unit-testable
1170
- * without a live Sentry client. `null`/`undefined` values are dropped so absent
1171
- * signals never become misleading `"null"`/`"undefined"` attributes. The run +
1172
- * project base context (version, command, ci/provider, framework, …) is already
1173
- * on the span from `withSentryRunSpan` / `recordSentryProjectContext`, so this
1174
- * adds only what those don't carry.
1175
- */
1176
- const buildRunEventAttributes = (input) => toSpanAttributes({
1177
- ...buildScanAttributes(input),
1178
- ...buildActionAttributes(),
1179
- ...buildOutcomeAttributes(input),
1180
- ...buildCacheAttributes(input)
1181
- });
1182
- /**
1183
- * Stamps the wide-event attributes onto the run's root span. A guarded no-op
1184
- * when tracing is off (no `rootSpan`) and swallow-on-throw, so telemetry can
1185
- * never break the run.
1186
- */
1187
- const recordRunEvent = (rootSpan, input) => {
1188
- if (!rootSpan) return;
1189
- try {
1190
- rootSpan.setAttributes(buildRunEventAttributes(input));
1191
- } catch {}
1192
- };
1193
- //#endregion
1194
- //#region src/cli/utils/diagnostic-intersects-line-ranges.ts
1195
- const diagnosticIntersectsLineRanges = (diagnostic, lineRanges) => {
1196
- const diagnosticEndLine = Math.max(diagnostic.line, diagnostic.endLine ?? diagnostic.line);
1197
- return lineRanges.some(([rangeStartLine, rangeEndLine]) => diagnostic.line <= rangeEndLine && diagnosticEndLine >= rangeStartLine);
1198
- };
1199
- //#endregion
1200
- //#region src/cli/utils/filter-diagnostics-by-changed-lines.ts
1201
- const filterDiagnosticsByChangedLines = (input) => {
1202
- const rangesByFile = /* @__PURE__ */ new Map();
1203
- for (const entry of input.changedLineRanges) rangesByFile.set(toForwardSlashes(entry.file), entry.ranges);
1204
- return input.diagnostics.filter((diagnostic) => {
1205
- const relativePath = toForwardSlashes(path.isAbsolute(diagnostic.filePath) ? path.relative(input.directory, diagnostic.filePath) : diagnostic.filePath);
1206
- const ranges = rangesByFile.get(relativePath);
1207
- return ranges !== void 0 && diagnosticIntersectsLineRanges(diagnostic, ranges);
1208
- });
1209
- };
1210
- //#endregion
1211
- //#region src/cli/utils/resolve-oxlint-node.ts
1212
- const consoleWarn = (message) => warn(highlighter.warn(message));
1213
- const consoleBreak = log("");
1214
- const consoleDim = (message) => log(highlighter.gray(message));
1215
- const consoleSuccess = (message) => log(highlighter.success(message));
1216
- const promptShouldInstallNode = () => promise(async () => {
1217
- const { shouldInstallNode } = await prompts({
1218
- type: "confirm",
1219
- name: "shouldInstallNode",
1220
- message: `Install Node 24 via nvm to enable lint checks?`,
1221
- initial: true
1222
- });
1223
- return Boolean(shouldInstallNode);
1224
- });
1225
- const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => gen(function* () {
1226
- if (!isLintEnabled) return null;
1227
- const resolver = yield* NodeResolver;
1228
- const initial = yield* resolver.resolve();
1229
- if (initial !== null) {
1230
- if (!initial.isCurrentNode && !isQuiet) {
1231
- yield* consoleWarn(`Node ${process.version} is unsupported by oxlint. Using Node ${initial.version} from nvm.`);
1232
- yield* consoleBreak;
1233
- }
1234
- return initial.binaryPath;
1235
- }
1236
- if (isQuiet) return null;
1237
- yield* consoleWarn(`Node ${process.version} is not compatible with oxlint (requires ${OXLINT_NODE_REQUIREMENT}). Lint checks will be skipped.`);
1238
- const isNvmInstalled = yield* resolver.isNvmInstalled();
1239
- if (isNvmInstalled && process.stdin.isTTY) {
1240
- if (yield* promptShouldInstallNode()) {
1241
- yield* consoleBreak;
1242
- const fresh = (yield* resolver.installViaNvm()) ? yield* resolver.resolve() : null;
1243
- if (fresh) {
1244
- yield* consoleBreak;
1245
- yield* consoleSuccess(`Node ${fresh.version} installed. Using it for lint checks.`);
1246
- yield* consoleBreak;
1247
- return fresh.binaryPath;
1248
- }
1249
- yield* consoleBreak;
1250
- yield* consoleWarn("Failed to install Node via nvm. Skipping lint checks.");
1251
- yield* consoleBreak;
1252
- return null;
1253
- }
1254
- } else if (isNvmInstalled) yield* consoleDim(` Run: nvm install 24`);
1255
- else yield* consoleDim(` Install nvm (https://github.com/nvm-sh/nvm) and run: nvm install 24`);
1256
- yield* consoleBreak;
1257
- return null;
1258
- });
1259
- const resolveOxlintNode = (isLintEnabled, isQuiet) => runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(provide(NodeResolver.layerNode)));
1260
- //#endregion
1261
- //#region src/cli/utils/resolve-cli-categories.ts
1262
- const categoryByLowercase = new Map(DIAGNOSTIC_CATEGORY_BUCKETS.map((category) => [category.toLowerCase(), category]));
1263
- const knownCategoryList = DIAGNOSTIC_CATEGORY_BUCKETS.join(", ");
1264
- const splitCategoryFlagValue = (value) => value.split(",").map((category) => category.trim()).filter((category) => category.length > 0);
1265
- const resolveCliCategories = (categoryFlag) => {
1266
- const rawCategoryValues = categoryFlag === void 0 ? [] : Array.isArray(categoryFlag) ? categoryFlag : [categoryFlag];
1267
- const resolvedCategories = [];
1268
- const seenCategories = /* @__PURE__ */ new Set();
1269
- for (const rawCategoryValue of rawCategoryValues) for (const categoryQuery of splitCategoryFlagValue(rawCategoryValue)) {
1270
- const matchedCategory = categoryByLowercase.get(categoryQuery.toLowerCase());
1271
- if (matchedCategory === void 0) throw new CliInputError(`Unknown category "${categoryQuery}". Expected one of: ${knownCategoryList}.`);
1272
- if (!seenCategories.has(matchedCategory)) {
1273
- seenCategories.add(matchedCategory);
1274
- resolvedCategories.push(matchedCategory);
1275
- }
1276
- }
1277
- if (rawCategoryValues.length > 0 && resolvedCategories.length === 0) throw new CliInputError(`--category requires one of: ${knownCategoryList}.`);
1278
- return resolvedCategories.length > 0 ? resolvedCategories : void 0;
1279
- };
1280
- //#endregion
1281
- //#region src/cli/utils/resolve-inspect-options.ts
1282
- const resolveIgnoredTags = (userConfig, includedTags) => {
1283
- const ignoredTags = new Set(userConfig?.ignore?.tags ?? []);
1284
- for (const includedTag of includedTags) ignoredTags.delete(includedTag);
1285
- return ignoredTags;
1286
- };
1287
- const resolveInspectOptions = (inputOptions, userConfig) => {
1288
- const includedTags = inputOptions.includedTags ?? /* @__PURE__ */ new Set();
1289
- const hasIncludedTags = includedTags.size > 0;
1290
- return {
1291
- lint: inputOptions.lint ?? userConfig?.lint ?? true,
1292
- deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
1293
- supplyChain: inputOptions.supplyChain ?? userConfig?.supplyChain?.enabled ?? true,
1294
- verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
1295
- outputDirectory: inputOptions.outputDirectory || null,
1296
- scoreOnly: inputOptions.scoreOnly ?? false,
1297
- noScore: inputOptions.noScore ?? userConfig?.noScore ?? false,
1298
- isCi: inputOptions.isCi ?? false,
1299
- isCiOrCodingAgentEnvironment: isCiOrCodingAgentEnvironment(),
1300
- isNonInteractiveEnvironment: isNonInteractiveEnvironment(),
1301
- silent: inputOptions.silent ?? false,
1302
- includePaths: inputOptions.includePaths ?? [],
1303
- customRulesOnly: hasIncludedTags ? false : userConfig?.customRulesOnly ?? false,
1304
- share: userConfig?.share ?? true,
1305
- respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
1306
- warnings: inputOptions.warnings ?? userConfig?.warnings ?? true,
1307
- categoryFilters: new Set(resolveCliCategories(inputOptions.categoryFilters) ?? []),
1308
- adoptExistingLintConfig: hasIncludedTags ? false : userConfig?.adoptExistingLintConfig ?? true,
1309
- ignoredTags: resolveIgnoredTags(userConfig, includedTags),
1310
- includedTags,
1311
- includeTagDefaults: inputOptions.includeTagDefaults ?? false,
1312
- scoreDisabledMessage: inputOptions.scoreDisabledMessage,
1313
- outputSurface: inputOptions.outputSurface ?? "cli",
1314
- suppressRendering: (inputOptions.suppressRendering ?? false) || inputOptions.uiLayers != null,
1315
- uiLayers: inputOptions.uiLayers ?? null,
1316
- concurrentScan: inputOptions.concurrentScan ?? false,
1317
- concurrency: inputOptions.concurrency,
1318
- maxDurationMs: inputOptions.maxDurationMs ?? null,
1319
- baseline: inputOptions.baseline ?? null,
1320
- changedLineRanges: inputOptions.changedLineRanges ?? null,
1321
- supplyChainManifestChanged: inputOptions.supplyChainManifestChanged ?? false,
1322
- excludedProjectDirectories: inputOptions.excludedProjectDirectories ?? [],
1323
- retainExcludedProjectDeadCodeDiagnostics: inputOptions.retainExcludedProjectDeadCodeDiagnostics ?? false,
1324
- precomputedSourceFileCount: inputOptions.precomputedSourceFileCount
1325
- };
1326
- };
1327
- //#endregion
1328
- //#region src/cli/utils/sum-file-counts-matching.ts
1329
- const sumFileCountsMatching = (messages, countPattern) => messages.reduce((total, message) => {
1330
- const match = countPattern.exec(message);
1331
- return match ? total + Number(match[1]) : total;
1332
- }, 0);
1333
- //#endregion
1334
- //#region src/cli/utils/count-deadline-skipped-files.ts
1335
- const DEADLINE_SKIPPED_MESSAGE_PATTERN = /^(\d+) file\(s\) skipped — max scan duration reached/;
1336
- const countDeadlineSkippedFiles = (lintPartialFailures) => sumFileCountsMatching(lintPartialFailures, DEADLINE_SKIPPED_MESSAGE_PATTERN);
1337
- //#endregion
1338
- //#region src/cli/utils/count-dropped-lint-files.ts
1339
- const DROPPED_FILES_MESSAGE_PATTERN = /^(\d+) file\(s\) failed to lint and were skipped/;
1340
- const countDroppedLintFiles = (lintPartialFailures) => sumFileCountsMatching(lintPartialFailures, DROPPED_FILES_MESSAGE_PATTERN);
1341
- //#endregion
1342
- //#region src/cli/utils/build-empty-report-message.ts
1343
- const buildEmptyReportMessage = (input) => {
1344
- const categoryFilters = [...input.categoryFilters];
1345
- if (categoryFilters.length > 0) return `No issues found in category ${categoryFilters.join(", ")}!`;
1346
- if (input.demotedDiagnosticCount > 0) return `No issues found! (${input.demotedDiagnosticCount} demoted from the ${input.outputSurface} surface — see config.surfaces.)`;
1347
- return "No issues found!";
1348
- };
1349
- //#endregion
1350
- //#region src/cli/utils/build-inspect-result.ts
1351
- const buildInspectResult = (input) => {
1352
- const { payload, cacheStats } = input;
1353
- const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
1354
- didLintFail: payload.didLintFail,
1355
- lintFailureReason: payload.lintFailureReason,
1356
- lintPartialFailures: payload.lintPartialFailures,
1357
- didDeadCodeFail: payload.didDeadCodeFail,
1358
- deadCodeFailureReason: payload.deadCodeFailureReason,
1359
- supplyChainOverlapTimedOut: payload.supplyChainOverlapTimedOut,
1360
- securityScanFailed: payload.securityScanFailed ?? false,
1361
- securityScanFailureReason: payload.securityScanFailureReason ?? null
1362
- });
1363
- return {
1364
- diagnostics: [...payload.diagnostics],
1365
- score: payload.score,
1366
- skippedChecks,
1367
- ...Object.keys(skippedCheckReasons).length > 0 ? { skippedCheckReasons } : {},
1368
- project: payload.project,
1369
- elapsedMilliseconds: input.elapsedMilliseconds,
1370
- scannedFileCount: payload.scannedFileCount,
1371
- scannedFilePaths: payload.scannedFilePaths,
1372
- analyzedFiles: payload.analyzedFiles ?? [],
1373
- scanElapsedMilliseconds: payload.scanElapsedMilliseconds,
1374
- ...cacheStats.lintCacheTotalFileCount === null ? {} : {
1375
- lintCacheHitFileCount: cacheStats.lintCacheHitFileCount,
1376
- lintCacheTotalFileCount: cacheStats.lintCacheTotalFileCount
1377
- },
1378
- ...cacheStats.lintSidecarTotalFileCount === null ? {} : {
1379
- lintSidecarReplayedFileCount: cacheStats.lintSidecarReplayedFileCount,
1380
- lintSidecarTotalFileCount: cacheStats.lintSidecarTotalFileCount
1381
- },
1382
- ...cacheStats.deadCodeCacheHit === null ? {} : { deadCodeCacheHit: cacheStats.deadCodeCacheHit },
1383
- ...cacheStats.deadCodeSummaryCacheHits === null || cacheStats.deadCodeSummaryCacheMisses === null ? {} : {
1384
- deadCodeSummaryCacheHits: cacheStats.deadCodeSummaryCacheHits,
1385
- deadCodeSummaryCacheMisses: cacheStats.deadCodeSummaryCacheMisses
1386
- },
1387
- ...payload.baselineDelta ? { baselineDelta: payload.baselineDelta } : {}
1388
- };
1389
- };
1390
- //#endregion
1391
- //#region src/cli/utils/build-no-score-message.ts
1392
- const ENTERPRISE_CONTACT_HINT = `Want something custom to your company? Contact us at ${ENTERPRISE_CONTACT_URL}.`;
1393
- const buildNoScoreMessage = (input) => {
1394
- const unavailableReason = resolveScoreUnavailableReason({
1395
- isScoreDisabled: input.isScoreDisabled,
1396
- isAnalysisIncomplete: input.isAnalysisIncomplete ?? false
1397
- });
1398
- let reason;
1399
- switch (unavailableReason) {
1400
- case "disabled":
1401
- reason = input.disabledMessage ?? "Score disabled by --no-score.";
1402
- break;
1403
- case "analysis-incomplete":
1404
- reason = "Score not shown because lint or dead-code analysis could not complete.";
1405
- break;
1406
- case "api-unavailable":
1407
- reason = "Score unavailable (could not reach the score API).";
1408
- break;
1409
- }
1410
- return `${reason} ${ENTERPRISE_CONTACT_HINT}`;
1411
- };
1412
- //#endregion
1413
- //#region src/cli/utils/filter-diagnostics-by-categories.ts
1414
- const filterDiagnosticsByCategories = (diagnostics, categories) => categories.size === 0 ? [...diagnostics] : diagnostics.filter((diagnostic) => categories.has(diagnostic.category));
1415
- //#endregion
1416
- //#region src/cli/utils/has-incomplete-score-analysis.ts
1417
- const hasIncompleteScoreAnalysis = (skippedChecks) => skippedChecks.includes("lint") || skippedChecks.includes("dead-code");
1418
- //#endregion
1419
- //#region src/cli/utils/format-diagnostic-site.ts
1420
- const formatDiagnosticSite = (site) => site.line > 0 ? `${site.filePath}:${site.line}` : site.filePath;
1421
- //#endregion
1422
- //#region src/cli/utils/format-rule-summary.ts
1423
- const formatFileContextTag = (diagnostic) => diagnostic.fileContext ? ` (${diagnostic.fileContext} file)` : "";
1424
- const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
1425
- const firstDiagnostic = ruleDiagnostics[0];
1426
- if (firstDiagnostic === void 0) return "";
1427
- const distinctMessages = [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.message))];
1428
- const sections = [
1429
- `Rule: ${ruleKey}`,
1430
- `Severity: ${firstDiagnostic.severity}`,
1431
- `Category: ${firstDiagnostic.category}`,
1432
- `Count: ${ruleDiagnostics.length}`,
1433
- "",
1434
- distinctMessages.join("\n\n")
1435
- ];
1436
- if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
1437
- if (firstDiagnostic.url) sections.push("", `Docs: ${firstDiagnostic.url}`);
1438
- const fixRecipeLine = formatFixRecipeLine(firstDiagnostic);
1439
- if (fixRecipeLine) sections.push("", fixRecipeLine);
1440
- sections.push("", "Files:");
1441
- for (const diagnostic of ruleDiagnostics) {
1442
- sections.push(` ${formatDiagnosticSite(diagnostic)}${formatFileContextTag(diagnostic)}`);
1443
- if (diagnostic.suppressionHint) sections.push(` ${diagnostic.suppressionHint}`);
1444
- }
1445
- return `${sections.join("\n")}\n`;
1446
- };
1447
- //#endregion
1448
- //#region src/cli/utils/write-diagnostics-directory.ts
1449
- const ruleDumpFileName = (ruleKey) => ruleKey.replace(/\//g, "--") + ".txt";
1450
- const readPreviousRuleDumpFileNames = (directory) => {
1451
- try {
1452
- const previous = JSON.parse(fs$1.readFileSync(path$1.join(directory, "diagnostics.json"), "utf8"));
1453
- return new Set(previous.filter((d) => typeof d?.plugin === "string" && typeof d?.rule === "string").map((d) => ruleDumpFileName(`${d.plugin}/${d.rule}`)));
1454
- } catch {
1455
- return /* @__PURE__ */ new Set();
1456
- }
1457
- };
1458
- const writeDiagnosticsDirectory = (diagnostics, outputDirectory) => {
1459
- const directory = outputDirectory ? path$1.resolve(outputDirectory) : path$1.join(tmpdir(), `react-doctor-${randomUUID()}`);
1460
- if (outputDirectory) for (const fileName of readPreviousRuleDumpFileNames(directory)) fs$1.rmSync(path$1.join(directory, fileName), { force: true });
1461
- fs$1.mkdirSync(directory, { recursive: true });
1462
- for (const [ruleKey, ruleDiagnostics] of buildSortedRuleGroups(diagnostics)) fs$1.writeFileSync(path$1.join(directory, ruleDumpFileName(ruleKey)), formatRuleSummary(ruleKey, ruleDiagnostics));
1463
- fs$1.writeFileSync(path$1.join(directory, "diagnostics.json"), JSON.stringify(diagnostics));
1464
- return directory;
1465
- };
1466
- //#endregion
1467
- //#region src/cli/utils/print-diagnostics-dump.ts
1468
- const printDiagnosticsDump = (diagnostics, outputDirectory, verbose, stream = "stdout") => gen(function* () {
1469
- const writtenDirectory = yield* try_({
1470
- try: () => writeDiagnosticsDirectory(diagnostics, outputDirectory),
1471
- catch: (cause) => cause
1472
- }).pipe(orElseSucceed(() => null));
1473
- if (writtenDirectory === null || !verbose && !outputDirectory) return;
1474
- const pathLine = highlighter.gray(` Full diagnostics written to ${writtenDirectory}`);
1475
- yield* stream === "stderr" || Boolean(outputDirectory) && isJsonModeActive() ? sync(() => process.stderr.write(`${pathLine}\n`)) : log(pathLine);
1476
- });
1477
- //#endregion
1478
- //#region src/cli/utils/collect-affected-files.ts
1479
- const collectAffectedFiles = (diagnostics) => new Set(diagnostics.map((diagnostic) => diagnostic.filePath));
1480
- //#endregion
1481
- //#region src/cli/utils/print-footer.ts
1482
- const buildShareUrl = (input) => {
1483
- const errorCount = input.diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
1484
- const warningCount = input.diagnostics.length - errorCount;
1485
- const affectedFileCount = collectAffectedFiles(input.diagnostics).size;
1486
- const parameters = new URLSearchParams({ p: input.projectName });
1487
- if (input.scoreResult) parameters.set("s", String(input.scoreResult.score));
1488
- if (errorCount > 0) parameters.set("e", String(errorCount));
1489
- if (warningCount > 0) parameters.set("w", String(warningCount));
1490
- if (affectedFileCount > 0) parameters.set("f", String(affectedFileCount));
1491
- return `${SHARE_BASE_URL}?${parameters.toString()}`;
1492
- };
1493
- const printFooter = (input) => gen(function* () {
1494
- yield* log("");
1495
- yield* log(buildSectionDivider());
1496
- yield* log("");
1497
- const shareUrl = input.isOffline ? null : buildShareUrl(input);
1498
- for (const line of buildFooterLinkLines({ shareUrl })) yield* log(line);
1499
- });
1500
- //#endregion
1501
- //#region src/cli/utils/format-elapsed-time.ts
1502
- const formatElapsedTime = (elapsedMilliseconds) => {
1503
- if (elapsedMilliseconds < 1e3) return `${Math.round(elapsedMilliseconds)}ms`;
1504
- return `${(elapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1)}s`;
1505
- };
1506
- //#endregion
1507
- //#region src/cli/utils/pluralize.ts
1508
- const pluralize = (count, singularNoun) => `${count} ${singularNoun}${count === 1 ? "" : "s"}`;
1509
- //#endregion
1510
- //#region src/cli/utils/print-headless-report.ts
1511
- const formatContextTag = (diagnostic) => diagnostic.fileContext ? ` (${diagnostic.fileContext} file)` : "";
1512
- const printHeadlessReport = (input) => gen(function* () {
1513
- yield* log(`${highlighter.success("✔")} Scanned ${pluralize(input.scannedFileCount, "file")} in ${formatElapsedTime(input.elapsedMilliseconds)}`);
1514
- yield* log("");
1515
- yield* log(highlighter.bold(`React Doctor — ${input.projectName}`));
1516
- if (input.scoreResult) yield* log(`Score: ${input.scoreResult.score} / 100 ${input.scoreResult.label}`);
1517
- else yield* log(highlighter.gray(input.noScoreMessage));
1518
- if (input.diagnostics.length === 0) {
1519
- yield* log("");
1520
- yield* log(highlighter.success(`✔ ${input.emptyStateMessage}`));
1521
- } else {
1522
- yield* log("");
1523
- yield* log(highlighter.bold(pluralize(input.diagnostics.length, "issue")));
1524
- const categoryCounts = /* @__PURE__ */ new Map();
1525
- for (const diagnostic of input.diagnostics) {
1526
- const counts = categoryCounts.get(diagnostic.category) ?? {
1527
- error: 0,
1528
- warning: 0
1529
- };
1530
- categoryCounts.set(diagnostic.category, {
1531
- error: counts.error + Number(diagnostic.severity === "error"),
1532
- warning: counts.warning + Number(diagnostic.severity === "warning")
1533
- });
1534
- }
1535
- for (const [category, counts] of categoryCounts) {
1536
- const countParts = [];
1537
- if (counts.error > 0) countParts.push(pluralize(counts.error, "error"));
1538
- if (counts.warning > 0) countParts.push(pluralize(counts.warning, "warning"));
1539
- yield* log(`${category}: ${countParts.join(", ")}`);
1540
- }
1541
- yield* log("");
1542
- for (const [ruleKey, ruleDiagnostics] of buildSortedRuleGroups(input.diagnostics)) {
1543
- const representativeDiagnostic = ruleDiagnostics[0];
1544
- if (representativeDiagnostic === void 0) continue;
1545
- const severityIcon = representativeDiagnostic.severity === "error" ? "✖" : "⚠";
1546
- const colorize = representativeDiagnostic.severity === "error" ? highlighter.error : highlighter.warn;
1547
- const occurrenceSuffix = ruleDiagnostics.length > 1 ? ` ×${ruleDiagnostics.length}` : "";
1548
- yield* log(colorize(`${severityIcon} ${representativeDiagnostic.title ?? ruleKey}${occurrenceSuffix}`));
1549
- yield* log(highlighter.gray(` ${ruleKey}`));
1550
- for (const diagnostic of ruleDiagnostics) yield* log(` ${formatDiagnosticSite(diagnostic)}${formatContextTag(diagnostic)}`);
1551
- }
1552
- }
1553
- if (input.skippedChecks.length > 0) {
1554
- yield* log("");
1555
- yield* warn(highlighter.warn(`Results are incomplete: ${input.skippedChecks.join(" and ")} checks failed.`));
1556
- }
1557
- });
1558
- //#endregion
1559
- //#region src/cli/utils/render-agent-guidance.ts
1560
- const AGENT_GUIDANCE_LINES = [
1561
- "Treat React Doctor diagnostics as starting hypotheses. Read the relevant code before confirming or suppressing each finding.",
1562
- "For each group, decide true positive, false positive, or needs-human-review, then assign high/medium/low confidence.",
1563
- "Do not suppress a finding without evidence from the file in question. Confidence requires code context.",
1564
- "Understand the root cause before editing. Fix the underlying code instead of changing react-doctor config or suppressing rules unless explicitly asked.",
1565
- "Investigate deeply where relevant: race conditions, security-sensitive flows, state propagation, multi-file refactors, and downstream dependency chains.",
1566
- "Ignore pure style preferences, theoretical issues without real impact, missing features, and unrelated pre-existing code.",
1567
- "Start with high-confidence fixes that preserve behavior. Leave low-confidence or product-dependent changes as notes.",
1568
- "Run `npx react-doctor@latest --verbose --scope changed` before and after changes, plus relevant tests after each focused batch.",
1569
- "When available, spawn subagents or isolated worktrees for independent rule families, then review and merge only the best safe fixes.",
1570
- "Split unrelated, broad, or behavior-changing work into separate PRs/branches instead of one large cleanup.",
1571
- "When one rule spans dozens of files (a migration-scale change), fix a representative sample first, confirm the recipe holds, and get the code owner's sign-off before changing the rest. Don't mass-fix a broad pattern in one unreviewed pass.",
1572
- "For confirmed issues that cannot be fixed now, create GitHub issues with the rule, file/line, confidence, impact, and proposed fix.",
1573
- "If a fix needs an API, UX, or architecture decision, stop and ask before editing."
1574
- ];
1575
- const printAgentGuidance = () => gen(function* () {
1576
- yield* log(`${highlighter.bold("Agent guidance")}`);
1577
- for (const line of AGENT_GUIDANCE_LINES) yield* log(highlighter.gray(` - ${line}`));
1578
- yield* log("");
1579
- });
1580
- //#endregion
1581
- //#region src/cli/utils/finalize-inspect-result.ts
1582
- const buildInspectPresentation = ({ options, payload }) => {
1583
- const surfaceDiagnostics = filterDiagnosticsForSurface([...payload.diagnostics], options.outputSurface, payload.userConfig);
1584
- return {
1585
- diagnostics: filterDiagnosticsByCategories(surfaceDiagnostics, options.categoryFilters),
1586
- demotedDiagnosticCount: payload.diagnostics.length - surfaceDiagnostics.length
1587
- };
1588
- };
1589
- const printScoreOnlyInspectResult = ({ options, payload, diagnostics, noScoreMessage }) => gen(function* () {
1590
- if (options.outputDirectory !== null) yield* printDiagnosticsDump(diagnostics, options.outputDirectory, false, "stderr");
1591
- if (payload.score) {
1592
- yield* log(`${payload.score.score}`);
1593
- return;
1594
- }
1595
- yield* error(highlighter.gray(noScoreMessage));
1596
- });
1597
- const printFullInspectResultDetails = ({ options, payload, diagnostics, demotedDiagnosticCount }) => gen(function* () {
1598
- if (options.outputDirectory !== null || options.verbose) yield* printDiagnosticsDump(diagnostics, options.outputDirectory, options.verbose);
1599
- if (options.categoryFilters.size === 0 && demotedDiagnosticCount > 0) {
1600
- yield* log(highlighter.gray(` ${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`));
1601
- yield* log("");
1602
- }
1603
- yield* printFooter({
1604
- diagnostics,
1605
- scoreResult: payload.score,
1606
- projectName: payload.project.projectName,
1607
- isOffline: options.isCi || !options.share || payload.score === null
1608
- });
1609
- });
1610
- const printFullInspectResult = ({ options, elapsedMilliseconds, payload, result, diagnostics, demotedDiagnosticCount, noScoreMessage }) => gen(function* () {
1611
- if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
1612
- yield* printHeadlessReport({
1613
- diagnostics,
1614
- elapsedMilliseconds,
1615
- emptyStateMessage: buildEmptyReportMessage({
1616
- categoryFilters: options.categoryFilters,
1617
- demotedDiagnosticCount,
1618
- outputSurface: options.outputSurface
1619
- }),
1620
- noScoreMessage,
1621
- projectName: payload.project.projectName,
1622
- scannedFileCount: payload.scannedFileCount,
1623
- scoreResult: result.skippedChecks.length > 0 ? null : payload.score,
1624
- skippedChecks: result.skippedChecks
1625
- });
1626
- yield* printFullInspectResultDetails({
1627
- options,
1628
- payload,
1629
- diagnostics,
1630
- demotedDiagnosticCount
1631
- });
1632
- });
1633
- const finalizeInspectResult = (input) => gen(function* () {
1634
- const { payload } = input;
1635
- const result = buildInspectResult(input);
1636
- const noScoreMessage = buildNoScoreMessage({
1637
- isScoreDisabled: input.options.noScore,
1638
- isAnalysisIncomplete: hasIncompleteScoreAnalysis(result.skippedChecks),
1639
- disabledMessage: input.options.scoreDisabledMessage
1640
- });
1641
- if (input.options.suppressRendering) return result;
1642
- const presentation = buildInspectPresentation(input);
1643
- if (input.options.scoreOnly) {
1644
- yield* printScoreOnlyInspectResult({
1645
- options: input.options,
1646
- payload,
1647
- diagnostics: presentation.diagnostics,
1648
- noScoreMessage
1649
- });
1650
- return result;
1651
- }
1652
- yield* printFullInspectResult({
1653
- ...input,
1654
- ...presentation,
1655
- result,
1656
- noScoreMessage
1657
- });
1658
- return result;
1659
- });
1660
- //#endregion
1661
- //#region src/cli/utils/resolve-worker-telemetry.ts
1662
- /**
1663
- * Projects the resolved lint worker count into the `(workerCount, parallel)`
1664
- * telemetry pair. `resolvedWorkerCount` is the count the scan actually fanned
1665
- * out to (`InspectOutput.scanConcurrency`); `pinnedConcurrency` is the caller's
1666
- * `inspect({ concurrency })` pin, used as the fallback when no scan resolved a
1667
- * count (the pre-scan failure path, or a cache entry persisted before the
1668
- * resolved count was tracked). `parallel` is derived from the count — NOT from
1669
- * whether a count was pinned — so the common auto path (no pin) still reports
1670
- * parallelism correctly instead of always reading `false`.
1671
- */
1672
- const resolveWorkerTelemetry = (resolvedWorkerCount, pinnedConcurrency) => {
1673
- const workerCount = resolvedWorkerCount ?? pinnedConcurrency;
1674
- return {
1675
- workerCount,
1676
- parallel: workerCount !== void 0 && workerCount > 1
1677
- };
1678
- };
1679
- //#endregion
1680
- //#region src/cli/utils/render-and-record-scan.ts
1681
- const silentConsole$2 = makeNoopConsole();
1682
- const deriveScope = (options) => {
1683
- if (options.baseline) return "changed";
1684
- if (options.changedLineRanges !== null) return "lines";
1685
- return options.includePaths.length > 0 ? "files" : "full";
1686
- };
1687
- const buildRunEventConfig = (options, userConfig, resolvedWorkerCount) => {
1688
- const { workerCount, parallel } = resolveWorkerTelemetry(resolvedWorkerCount, options.concurrency);
1689
- return {
1690
- scope: deriveScope(options),
1691
- parallel,
1692
- workerCount,
1693
- maxDurationMs: options.maxDurationMs,
1694
- lint: options.lint,
1695
- deadCode: options.deadCode,
1696
- supplyChain: options.supplyChain,
1697
- scoreOnly: options.scoreOnly,
1698
- noScore: options.noScore,
1699
- respectInlineDisables: options.respectInlineDisables,
1700
- showWarnings: options.warnings,
1701
- usedOutputDir: options.outputDirectory !== null,
1702
- ignoredTagCount: options.ignoredTags.size,
1703
- hasCustomConfig: userConfig !== null,
1704
- userConfig
1705
- };
1706
- };
1707
- const renderAndRecordScan = async (input) => {
1708
- const cacheStats = {
1709
- lintCacheHitFileCount: input.cacheStats?.lintCacheHitFileCount ?? null,
1710
- lintCacheTotalFileCount: input.cacheStats?.lintCacheTotalFileCount ?? null,
1711
- lintSidecarReplayedFileCount: input.cacheStats?.lintSidecarReplayedFileCount ?? null,
1712
- lintSidecarTotalFileCount: input.cacheStats?.lintSidecarTotalFileCount ?? null,
1713
- deadCodeCacheHit: input.cacheStats?.deadCodeCacheHit ?? null,
1714
- deadCodeSummaryCacheHits: input.cacheStats?.deadCodeSummaryCacheHits ?? null,
1715
- deadCodeSummaryCacheMisses: input.cacheStats?.deadCodeSummaryCacheMisses ?? null
1716
- };
1717
- const finalizeEffect = finalizeInspectResult({
1718
- options: input.options,
1719
- elapsedMilliseconds: performance.now() - input.startTime,
1720
- payload: input.payload,
1721
- cacheStats
1722
- });
1723
- const result = await runPromise(input.options.silent ? finalizeEffect.pipe(provideService(Console, silentConsole$2)) : finalizeEffect);
1724
- const { workerCount: resolvedWorkerCount, parallel } = resolveWorkerTelemetry(input.payload.scanConcurrency, input.options.concurrency);
1725
- recordScanMetrics({
1726
- result,
1727
- mode: input.scanMode,
1728
- baselineDegraded: input.baselineDegraded,
1729
- parallel,
1730
- workerCount: resolvedWorkerCount,
1731
- lint: input.options.lint,
1732
- deadCode: input.options.deadCode,
1733
- scoreOnly: input.options.scoreOnly,
1734
- noScore: input.options.noScore,
1735
- didLintFail: input.payload.didLintFail,
1736
- lintFailureReasonKind: input.payload.lintFailureReasonKind,
1737
- didDeadCodeFail: input.payload.didDeadCodeFail,
1738
- userConfig: input.payload.userConfig,
1739
- suppressedRuleCounts: input.payload.suppressedRuleCounts ?? []
1740
- });
1741
- recordRunEvent(input.rootSentrySpan, {
1742
- ...buildRunEventConfig(input.options, input.payload.userConfig, resolvedWorkerCount),
1743
- result,
1744
- mode: input.scanMode,
1745
- gateExempt: input.baselineDegraded,
1746
- wholeRepoCacheHit: input.wholeRepoCacheHit,
1747
- didLintFail: input.payload.didLintFail,
1748
- lintFailureReasonKind: input.payload.lintFailureReasonKind,
1749
- lintPartialFailureCount: input.payload.lintPartialFailures.length,
1750
- lintDroppedFileCount: countDroppedLintFiles(input.payload.lintPartialFailures),
1751
- lintDeadlineSkippedFileCount: countDeadlineSkippedFiles(input.payload.lintPartialFailures),
1752
- didDeadCodeFail: input.payload.didDeadCodeFail,
1753
- supplyChainOverlapTimedOut: input.payload.supplyChainOverlapTimedOut,
1754
- securityScanFailed: input.payload.securityScanFailed,
1755
- deadCodeOverlapped: input.payload.deadCodeOverlapped,
1756
- suppressedRuleCounts: input.payload.suppressedRuleCounts ?? []
1757
- });
1758
- return result;
1759
- };
1760
- //#endregion
1761
- //#region src/cli/utils/read-diagnostic-evidence.ts
1762
- const SOURCE_EXTENSIONS = [
1763
- ".ts",
1764
- ".tsx",
1765
- ".js",
1766
- ".jsx",
1767
- ".mts",
1768
- ".cts",
1769
- ".mjs",
1770
- ".cjs"
1771
- ];
1772
- const COMPONENT_WRAPPER_NAMES = new Set(["memo", "forwardRef"]);
1773
- const getScriptKind = (filePath) => {
1774
- const extension = path.extname(filePath).toLowerCase();
1775
- if (extension === ".tsx") return ts.ScriptKind.TSX;
1776
- if (extension === ".jsx") return ts.ScriptKind.JSX;
1777
- if (extension === ".js" || extension === ".mjs" || extension === ".cjs") return ts.ScriptKind.JS;
1778
- return ts.ScriptKind.TS;
1779
- };
1780
- const resolveSafeSourcePath = (rootDirectory, filePath) => {
1781
- try {
1782
- const resolvedRootDirectory = fs.realpathSync(rootDirectory);
1783
- const absolutePath = path.resolve(resolvedRootDirectory, filePath);
1784
- const relativePath = path.relative(resolvedRootDirectory, absolutePath);
1785
- if (relativePath.length === 0 || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) return null;
1786
- const stats = fs.lstatSync(absolutePath);
1787
- if (!stats.isFile() || stats.isSymbolicLink()) return null;
1788
- const realPath = fs.realpathSync(absolutePath);
1789
- const realRelativePath = path.relative(resolvedRootDirectory, realPath);
1790
- return realRelativePath !== ".." && !realRelativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(realRelativePath) ? realPath : null;
1791
- } catch {
1792
- return null;
1793
- }
1794
- };
1795
- const readSourceRecord = (rootDirectory, filePath, state) => {
1796
- const normalizedFilePath = filePath.replace(/\\/g, "/");
1797
- if (state.recordByFilePath.has(normalizedFilePath)) return state.recordByFilePath.get(normalizedFilePath) ?? null;
1798
- const absolutePath = resolveSafeSourcePath(rootDirectory, normalizedFilePath);
1799
- if (absolutePath === null) {
1800
- state.recordByFilePath.set(normalizedFilePath, null);
1801
- return null;
1802
- }
1803
- try {
1804
- const sourceText = fs.readFileSync(absolutePath, "utf-8");
1805
- const record = {
1806
- absolutePath,
1807
- filePath: normalizedFilePath,
1808
- sourceFile: ts.createSourceFile(absolutePath, sourceText, ts.ScriptTarget.Latest, true, getScriptKind(absolutePath)),
1809
- sourceText
1810
- };
1811
- state.recordByFilePath.set(normalizedFilePath, record);
1812
- return record;
1813
- } catch {
1814
- state.recordByFilePath.set(normalizedFilePath, null);
1815
- return null;
1816
- }
1817
- };
1818
- const listSourceRecords = (rootDirectory, state) => {
1819
- if (state.sourceRecords !== null) return state.sourceRecords;
1820
- state.sourceRecords = listSourceFiles(rootDirectory).flatMap((filePath) => {
1821
- const record = readSourceRecord(rootDirectory, filePath, state);
1822
- return record === null ? [] : [record];
1823
- });
1824
- return state.sourceRecords;
1825
- };
1826
- const unwrapExpression = (expression) => {
1827
- let currentExpression = expression;
1828
- while (ts.isParenthesizedExpression(currentExpression) || ts.isAsExpression(currentExpression) || ts.isTypeAssertionExpression(currentExpression) || ts.isNonNullExpression(currentExpression) || ts.isSatisfiesExpression(currentExpression)) currentExpression = currentExpression.expression;
1829
- return currentExpression;
1830
- };
1831
- const getCalleeName = (expression) => {
1832
- const unwrappedExpression = unwrapExpression(expression);
1833
- if (ts.isIdentifier(unwrappedExpression)) return unwrappedExpression.text;
1834
- if (ts.isPropertyAccessExpression(unwrappedExpression)) return unwrappedExpression.name.text;
1835
- return null;
1836
- };
1837
- const isComponentWrapperCall = (node) => {
1838
- const wrapperName = getCalleeName(node.expression);
1839
- return wrapperName !== null && COMPONENT_WRAPPER_NAMES.has(wrapperName);
1840
- };
1841
- const getFunctionName = (node) => {
1842
- if (ts.isFunctionDeclaration(node) && node.name !== void 0) return node.name.text;
1843
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
1844
- let componentExpression = node;
1845
- while (true) {
1846
- const parent = componentExpression.parent;
1847
- if (ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent) || ts.isTypeAssertionExpression(parent) || ts.isNonNullExpression(parent) || ts.isSatisfiesExpression(parent)) {
1848
- componentExpression = parent;
1849
- continue;
1850
- }
1851
- if (ts.isCallExpression(parent) && parent.arguments[0] === componentExpression && isComponentWrapperCall(parent)) {
1852
- componentExpression = parent;
1853
- continue;
1854
- }
1855
- if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name.text;
1856
- return node.name?.text ?? null;
1857
- }
1858
- }
1859
- return null;
1860
- };
1861
- const isComponentFunction = (node) => ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node);
1862
- const isDefaultExportedComponent = (record, node, componentName) => {
1863
- if (ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true;
1864
- return record.sourceFile.statements.some((statement) => ts.isExportAssignment(statement) && ts.isIdentifier(statement.expression) && statement.expression.text === componentName);
1865
- };
1866
- const findEnclosingComponent = (record, diagnostic) => {
1867
- if (!Number.isInteger(diagnostic.line) || diagnostic.line < 1) return null;
1868
- const lineIndex = diagnostic.line - 1;
1869
- const lineStarts = record.sourceFile.getLineStarts();
1870
- const lineStart = lineStarts[lineIndex];
1871
- if (lineStart === void 0) return null;
1872
- const nextLineStart = lineStarts[lineIndex + 1] ?? record.sourceFile.end;
1873
- const lineEnd = Math.max(lineStart, nextLineStart - 1);
1874
- const columnIndex = Number.isInteger(diagnostic.column) ? Math.max(0, diagnostic.column - 1) : 0;
1875
- const diagnosticPosition = Math.min(lineStart + columnIndex, lineEnd);
1876
- let component = null;
1877
- let componentWidth = Number.POSITIVE_INFINITY;
1878
- const visit = (node) => {
1879
- if (diagnosticPosition < node.getStart(record.sourceFile) || diagnosticPosition > node.end) return;
1880
- if (isComponentFunction(node)) {
1881
- const name = getFunctionName(node);
1882
- const width = node.end - node.getStart(record.sourceFile);
1883
- if (name !== null && /^[A-Z]/.test(name) && width < componentWidth) {
1884
- component = {
1885
- isDefaultExport: isDefaultExportedComponent(record, node, name),
1886
- name,
1887
- node,
1888
- record
1889
- };
1890
- componentWidth = width;
1891
- }
1892
- }
1893
- ts.forEachChild(node, visit);
1894
- };
1895
- visit(record.sourceFile);
1896
- return component;
1897
- };
1898
- const moduleSpecifierTargetsComponent = (callerRecord, moduleSpecifier, componentRecord) => {
1899
- if (!moduleSpecifier.startsWith(".")) return false;
1900
- const unresolvedPath = path.resolve(path.dirname(callerRecord.absolutePath), moduleSpecifier);
1901
- const specifiedExtension = path.extname(unresolvedPath).toLowerCase();
1902
- const sourcePathWithoutExtension = SOURCE_EXTENSIONS.includes(specifiedExtension) ? unresolvedPath.slice(0, -specifiedExtension.length) : unresolvedPath;
1903
- return [
1904
- unresolvedPath,
1905
- ...SOURCE_EXTENSIONS.map((extension) => `${sourcePathWithoutExtension}${extension}`),
1906
- ...sourcePathWithoutExtension === unresolvedPath ? SOURCE_EXTENSIONS.map((extension) => path.join(unresolvedPath, `index${extension}`)) : []
1907
- ].includes(componentRecord.absolutePath);
1908
- };
1909
- const getComponentTagNames = (record, component) => {
1910
- if (record.absolutePath === component.record.absolutePath) return new Set([component.name]);
1911
- const tagNames = /* @__PURE__ */ new Set();
1912
- for (const statement of record.sourceFile.statements) {
1913
- if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !moduleSpecifierTargetsComponent(record, statement.moduleSpecifier.text, component.record)) continue;
1914
- const importClause = statement.importClause;
1915
- if (component.isDefaultExport && importClause?.name !== void 0) tagNames.add(importClause.name.text);
1916
- const namedBindings = importClause?.namedBindings;
1917
- if (namedBindings !== void 0 && ts.isNamedImports(namedBindings)) {
1918
- for (const element of namedBindings.elements) if ((element.propertyName?.text ?? element.name.text) === component.name) tagNames.add(element.name.text);
1919
- }
1920
- }
1921
- return tagNames;
1922
- };
1923
- const getTransparentCall = (functionNode) => {
1924
- if (functionNode.body === void 0) return null;
1925
- if (!ts.isBlock(functionNode.body)) {
1926
- const expression = unwrapExpression(functionNode.body);
1927
- return ts.isCallExpression(expression) ? expression : null;
1928
- }
1929
- if (functionNode.body.statements.length !== 1) return null;
1930
- const statement = functionNode.body.statements[0];
1931
- if (statement === void 0) return null;
1932
- const expression = ts.isExpressionStatement(statement) || ts.isReturnStatement(statement) ? statement.expression : void 0;
1933
- if (expression === void 0) return null;
1934
- const unwrappedExpression = unwrapExpression(expression);
1935
- return ts.isCallExpression(unwrappedExpression) ? unwrappedExpression : null;
1936
- };
1937
- const resolveTransparentTarget = (functionNode) => {
1938
- const call = getTransparentCall(functionNode);
1939
- if (call === null) return null;
1940
- const target = unwrapExpression(call.expression);
1941
- if (!ts.isIdentifier(target) || call.arguments.length !== functionNode.parameters.length) return null;
1942
- for (const [parameterIndex, parameter] of functionNode.parameters.entries()) {
1943
- const argument = call.arguments[parameterIndex];
1944
- const unwrappedArgument = argument === void 0 ? null : unwrapExpression(argument);
1945
- if (!ts.isIdentifier(parameter.name) || unwrappedArgument === null || !ts.isIdentifier(unwrappedArgument) || unwrappedArgument.text !== parameter.name.text) return null;
1946
- }
1947
- return target.text;
1948
- };
1949
- const getForwardingFunction = (declaration) => {
1950
- if (ts.isFunctionDeclaration(declaration)) return declaration;
1951
- if (!ts.isVariableDeclaration(declaration) || declaration.initializer === void 0) return null;
1952
- const initializer = unwrapExpression(declaration.initializer);
1953
- if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer;
1954
- if (ts.isCallExpression(initializer) && getCalleeName(initializer.expression) === "useCallback") {
1955
- const callback = initializer.arguments[0];
1956
- if (callback !== void 0 && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) return callback;
1957
- }
1958
- return null;
1959
- };
1960
- const getDeclaredBindingName = (node) => {
1961
- if ((ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node) || ts.isFunctionDeclaration(node)) && node.name !== void 0 && ts.isIdentifier(node.name)) return node.name.text;
1962
- if (ts.isImportClause(node)) return node.name?.text ?? null;
1963
- if (ts.isImportSpecifier(node) || ts.isNamespaceImport(node)) return node.name.text;
1964
- return null;
1965
- };
1966
- const findBindingScope = (node) => {
1967
- let currentNode = node;
1968
- while (currentNode !== void 0) {
1969
- if (ts.isParameter(currentNode) && ts.isFunctionLike(currentNode.parent)) return currentNode.parent;
1970
- if (ts.isBlock(currentNode) || ts.isModuleBlock(currentNode) || ts.isSourceFile(currentNode)) return currentNode;
1971
- currentNode = currentNode.parent;
1972
- }
1973
- return node.getSourceFile();
1974
- };
1975
- const findBindingDeclaration = (record, binding) => {
1976
- const usagePosition = binding.getStart(record.sourceFile);
1977
- const candidates = [];
1978
- const visit = (node) => {
1979
- if (getDeclaredBindingName(node) === binding.text) {
1980
- const scope = findBindingScope(node);
1981
- const isVisibleScope = usagePosition >= scope.getStart(record.sourceFile) && usagePosition <= scope.end;
1982
- const isVisibleDeclaration = ts.isFunctionDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node) || ts.isImportClause(node) || ts.isImportSpecifier(node) || ts.isNamespaceImport(node) || node.getStart(record.sourceFile) < usagePosition;
1983
- if (isVisibleScope && isVisibleDeclaration) candidates.push({
1984
- declaration: node,
1985
- scope
1986
- });
1987
- }
1988
- ts.forEachChild(node, visit);
1989
- };
1990
- visit(record.sourceFile);
1991
- let nearestCandidate = null;
1992
- let nearestScopeWidth = Number.POSITIVE_INFINITY;
1993
- let isNearestScopeAmbiguous = false;
1994
- for (const candidate of candidates) {
1995
- const scopeWidth = candidate.scope.end - candidate.scope.getStart(record.sourceFile);
1996
- if (scopeWidth < nearestScopeWidth) {
1997
- nearestCandidate = candidate;
1998
- nearestScopeWidth = scopeWidth;
1999
- isNearestScopeAmbiguous = false;
2000
- } else if (scopeWidth === nearestScopeWidth) isNearestScopeAmbiguous = true;
2001
- }
2002
- if (isNearestScopeAmbiguous) return null;
2003
- const declaration = nearestCandidate?.declaration;
2004
- return declaration !== void 0 && (ts.isVariableDeclaration(declaration) || ts.isFunctionDeclaration(declaration)) ? declaration : null;
2005
- };
2006
- const resolveHandlerBinding = (record, binding) => {
2007
- const declaration = findBindingDeclaration(record, binding);
2008
- if (declaration === null) return binding.text;
2009
- const forwardingFunction = getForwardingFunction(declaration);
2010
- const target = forwardingFunction === null ? null : resolveTransparentTarget(forwardingFunction);
2011
- return target === null || target === binding.text ? binding.text : target;
2012
- };
2013
- const getJsxTagName = (node) => ts.isIdentifier(node.tagName) ? node.tagName.text : null;
2014
- const getHandlerBinding = (node, propName) => {
2015
- for (const property of node.attributes.properties) {
2016
- if (!ts.isJsxAttribute(property) || property.name.getText() !== propName) continue;
2017
- const initializer = property.initializer;
2018
- if (initializer === void 0 || !ts.isJsxExpression(initializer)) return null;
2019
- if (initializer.expression === void 0) return null;
2020
- const expression = unwrapExpression(initializer.expression);
2021
- return ts.isIdentifier(expression) ? expression : null;
2022
- }
2023
- return null;
2024
- };
2025
- const getForwardedHandlerAliases = (rootDirectory, component, propNames, state) => {
2026
- const cacheKey = `${component.record.filePath}\0${component.name}\0${[...propNames].sort().join("\0")}`;
2027
- const cachedAliases = state.aliasesByComponent.get(cacheKey);
2028
- if (cachedAliases !== void 0) return cachedAliases;
2029
- const bindingsByPropName = /* @__PURE__ */ new Map();
2030
- const unresolvedPropNames = /* @__PURE__ */ new Set();
2031
- let callsiteCount = 0;
2032
- for (const record of listSourceRecords(rootDirectory, state)) {
2033
- const tagNames = getComponentTagNames(record, component);
2034
- if (tagNames.size === 0) continue;
2035
- const visit = (node) => {
2036
- if (record === component.record && node === component.node) return;
2037
- if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
2038
- const tagName = getJsxTagName(node);
2039
- if (tagName !== null && tagNames.has(tagName)) {
2040
- callsiteCount += 1;
2041
- for (const propName of propNames) {
2042
- const binding = getHandlerBinding(node, propName);
2043
- if (binding === null) {
2044
- unresolvedPropNames.add(propName);
2045
- continue;
2046
- }
2047
- const bindings = bindingsByPropName.get(propName) ?? /* @__PURE__ */ new Set();
2048
- bindings.add(resolveHandlerBinding(record, binding));
2049
- bindingsByPropName.set(propName, bindings);
2050
- }
2051
- }
2052
- }
2053
- ts.forEachChild(node, visit);
2054
- };
2055
- visit(record.sourceFile);
2056
- }
2057
- const aliases = /* @__PURE__ */ new Map();
2058
- if (callsiteCount > 0) for (const [propName, bindings] of bindingsByPropName) {
2059
- if (bindings.size !== 1 || unresolvedPropNames.has(propName)) continue;
2060
- const [bindingName] = bindings;
2061
- if (bindingName !== void 0) aliases.set(propName, bindingName);
2062
- }
2063
- state.aliasesByComponent.set(cacheKey, aliases);
2064
- return aliases;
2065
- };
2066
- const normalizeForwardedHandlers = (evidence, aliases) => {
2067
- let normalizedEvidence = evidence;
2068
- for (const [propName, bindingName] of aliases) normalizedEvidence = normalizedEvidence.replace(new RegExp(`\\{\\s*${propName}\\s*\\}`, "g"), `{${bindingName}}`).replace(new RegExp(`=>\\s*${propName}\\s*\\(`, "g"), `=> ${bindingName}(`);
2069
- return normalizedEvidence;
2070
- };
2071
- const createDiagnosticEvidenceReader = (rootDirectory, options = {}) => {
2072
- const state = {
2073
- aliasesByComponent: /* @__PURE__ */ new Map(),
2074
- recordByFilePath: /* @__PURE__ */ new Map(),
2075
- sourceRecords: null
2076
- };
2077
- return (diagnostic) => {
2078
- const record = readSourceRecord(rootDirectory, diagnostic.filePath, state);
2079
- if (record === null || !Number.isInteger(diagnostic.line)) return null;
2080
- const sourceLines = record.sourceText.split(/\r?\n/);
2081
- const startLineIndex = Math.max(0, diagnostic.line - 1);
2082
- const endLineIndex = Math.max(startLineIndex, (diagnostic.endLine ?? diagnostic.line) - 1);
2083
- const evidence = sourceLines.slice(startLineIndex, endLineIndex + 1).join("\n");
2084
- if (!options.resolveForwardedHandlers) return evidence;
2085
- const propNames = new Set(evidence.match(/\bon[A-Z]\w*\b/g) ?? []);
2086
- if (propNames.size === 0) return evidence;
2087
- const component = findEnclosingComponent(record, diagnostic);
2088
- if (component === null) return evidence;
2089
- return normalizeForwardedHandlers(evidence, getForwardedHandlerAliases(rootDirectory, component, propNames, state));
2090
- };
2091
- };
2092
- //#endregion
2093
- //#region src/cli/utils/read-source-line.ts
2094
- /**
2095
- * Builds a cached `(filePath, line) -> source text` reader rooted at
2096
- * `rootDirectory`, used as the fallback when construct-level diagnostic
2097
- * evidence cannot be read. Wraps core's
2098
- * `createNodeReadFileLinesSync` with per-file memoization + 1-indexed line
2099
- * lookup; unreadable files / out-of-range lines return `null`.
2100
- */
2101
- const createSourceLineReader = (rootDirectory) => {
2102
- const readFileLines = createNodeReadFileLinesSync(rootDirectory);
2103
- const fileLinesCache = /* @__PURE__ */ new Map();
2104
- return (filePath, line) => {
2105
- if (!fileLinesCache.has(filePath)) fileLinesCache.set(filePath, readFileLines(filePath));
2106
- const lines = fileLinesCache.get(filePath) ?? null;
2107
- if (lines === null || line < 1 || line > lines.length) return null;
2108
- return lines[line - 1] ?? null;
2109
- };
2110
- };
2111
- //#endregion
2112
- //#region src/cli/utils/is-materializable-git-source.ts
2113
- const GIT_LFS_POINTER_HEADER = "version https://git-lfs.github.com/spec/v1";
2114
- const isMaterializableGitSource = (content) => !content.includes("\0") && !content.startsWith(GIT_LFS_POINTER_HEADER);
2115
- //#endregion
2116
- //#region src/cli/utils/materialize-baseline-files.ts
2117
- /**
2118
- * Materializes the base side of the `ref` → worktree diff into
2119
- * `tempDirectory`, mirroring the project layout plus head's config files so
2120
- * both sides lint under the same rules. Git rename heuristics are disabled by
2121
- * the diff planner, so an old path is retained as a base deletion while its
2122
- * new path is a head addition. Missing head-only additions are expected;
2123
- * missing paths that the plan says must exist at base make `isComplete` false.
2124
- */
2125
- const materializeBaselineFiles = (input) => runPromise(gen(function* () {
2126
- const git = yield* Git;
2127
- const diffPlan = yield* git.baselineDiffPlan({
2128
- directory: input.directory,
2129
- ref: input.ref
2130
- });
2131
- const baseFiles = input.baseFiles ?? diffPlan?.baseFiles;
2132
- const headFiles = input.headFiles ?? diffPlan?.headFiles;
2133
- const untrackedFiles = diffPlan?.untrackedFiles ?? [];
2134
- if (baseFiles === void 0 || headFiles === void 0) return null;
2135
- const files = [...new Set([...input.files, ...baseFiles])];
2136
- const tree = yield* materializeSourceTree({
2137
- directory: input.directory,
2138
- files,
2139
- tempDirectory: input.tempDirectory,
2140
- readContent: (relativePath) => git.showRefContent({
2141
- directory: input.directory,
2142
- ref: input.ref,
2143
- relativePath,
2144
- options: { maxBufferBytes: GIT_SHOW_MAX_BUFFER_BYTES }
2145
- }).pipe(map((content) => content !== null && isMaterializableGitSource(content) ? content : null))
2146
- });
2147
- const materializedFiles = new Set(tree.materializedFiles);
2148
- return {
2149
- ...tree,
2150
- baseFiles,
2151
- headFiles,
2152
- isComplete: baseFiles.every((filePath) => materializedFiles.has(filePath)),
2153
- untrackedFiles
2154
- };
2155
- }).pipe(provide(Git.layerNode)));
2156
- /**
2157
- * Resolves the commit a baseline scan should read base content from — the
2158
- * merge-base of `ref` and HEAD, so "introduced" is measured against the branch
2159
- * point. `null` when unresolvable (ref missing/unsafe, no merge-base).
2160
- */
2161
- const resolveMergeBaseRef = async (directory, ref) => {
2162
- try {
2163
- return await runPromise(gen(function* () {
2164
- return yield* (yield* Git).mergeBase({
2165
- directory,
2166
- ref
2167
- });
2168
- }).pipe(provide(Git.layerNode)));
2169
- } catch {
2170
- return null;
2171
- }
2172
- };
2173
- //#endregion
2174
- //#region src/cli/utils/run-baseline-comparison.ts
2175
- const silentConsole$1 = makeNoopConsole();
2176
- const countIncompleteLintFiles = (lintPartialFailures) => countDroppedLintFiles(lintPartialFailures) + countDeadlineSkippedFiles(lintPartialFailures);
2177
- const runBaselineComparison = async (input) => {
2178
- const temporaryDirectory = mkdtempSync(path.join(tmpdir(), BASELINE_FILES_TEMP_DIR_PREFIX));
2179
- const baselineIncludePaths = filterPathsOutsideDirectories({
2180
- rootDirectory: input.directory,
2181
- relativePaths: input.options.includePaths,
2182
- excludedDirectories: input.options.excludedProjectDirectories
2183
- });
2184
- const baselineBaseFiles = input.baseFiles ? filterPathsOutsideDirectories({
2185
- rootDirectory: input.directory,
2186
- relativePaths: input.baseFiles,
2187
- excludedDirectories: input.options.excludedProjectDirectories
2188
- }) : void 0;
2189
- const baselineHeadFiles = input.headFiles ? filterPathsOutsideDirectories({
2190
- rootDirectory: input.directory,
2191
- relativePaths: input.headFiles,
2192
- excludedDirectories: input.options.excludedProjectDirectories
2193
- }) : void 0;
2194
- const snapshot = await materializeBaselineFiles({
2195
- directory: input.directory,
2196
- ref: input.baselineRef,
2197
- files: baselineIncludePaths,
2198
- baseFiles: baselineBaseFiles,
2199
- headFiles: baselineHeadFiles,
2200
- tempDirectory: temporaryDirectory
2201
- }).catch((error) => {
2202
- rmSync(temporaryDirectory, {
2203
- recursive: true,
2204
- force: true
2205
- });
2206
- throw error;
2207
- });
2208
- if (snapshot === null) {
2209
- rmSync(temporaryDirectory, {
2210
- recursive: true,
2211
- force: true
2212
- });
2213
- return null;
2214
- }
2215
- try {
2216
- if (!snapshot.isComplete) return null;
2217
- const analyzedHeadFiles = new Set(input.headAnalyzedFiles.map(toForwardSlashes));
2218
- const baseFiles = new Set(snapshot.baseFiles.map(toForwardSlashes));
2219
- const expectedHeadFiles = new Set(snapshot.headFiles.map(toForwardSlashes));
2220
- for (const filePath of baselineIncludePaths) {
2221
- const normalizedFilePath = toForwardSlashes(filePath);
2222
- if (!baseFiles.has(normalizedFilePath)) expectedHeadFiles.add(normalizedFilePath);
2223
- }
2224
- if (filterSourceFiles([...expectedHeadFiles]).some((filePath) => !analyzedHeadFiles.has(filePath))) return null;
2225
- const runtimeLayers = buildRuntimeLayers({
2226
- directory: snapshot.tempDirectory,
2227
- hasConfigOverride: true,
2228
- userConfig: input.userConfig,
2229
- configSourceDirectory: input.configSourceDirectory,
2230
- projectInfoOverride: input.headProjectInfo,
2231
- shouldSkipLint: !input.options.lint || !input.resolvedNodeBinaryPath,
2232
- shouldRunDeadCode: false,
2233
- shouldRunSupplyChain: input.options.supplyChain,
2234
- shouldComputeScore: false,
2235
- shouldShowProgressSpinners: false,
2236
- oxlintConcurrency: input.oxlintRuntime.concurrency,
2237
- oxlintSpawnSlots: input.oxlintRuntime.spawnSlots
2238
- });
2239
- const baseOutput = await runPromise(restoreLegacyThrow(runInspect({
2240
- directory: snapshot.tempDirectory,
2241
- includePaths: snapshot.materializedFiles,
2242
- customRulesOnly: input.options.customRulesOnly,
2243
- respectInlineDisables: input.options.respectInlineDisables,
2244
- warnings: input.options.warnings,
2245
- adoptExistingLintConfig: input.options.adoptExistingLintConfig,
2246
- ignoredTags: input.options.ignoredTags,
2247
- includedTags: input.options.includedTags,
2248
- includeTagDefaults: input.options.includeTagDefaults,
2249
- nodeBinaryPath: input.resolvedNodeBinaryPath ?? void 0,
2250
- runDeadCode: false,
2251
- isCi: input.options.isCi,
2252
- doctorVersion: VERSION,
2253
- runId: getRunId(),
2254
- resolveLocalGithubViewerPermission: false,
2255
- suppressScanSummary: true,
2256
- supplyChainManifestChanged: input.options.supplyChainManifestChanged,
2257
- deadlineEpochMs: input.deadlineEpochMs ?? void 0,
2258
- signal: input.oxlintRuntime.abortSignal
2259
- }, {}).pipe(provide(runtimeLayers), provideService(PerFileLintCacheEnabled, false), provideService(SidecarLintCacheEnabled, false), provideService(Console, silentConsole$1))), { signal: input.oxlintRuntime.abortSignal });
2260
- if (baseOutput.didLintFail || countIncompleteLintFiles(baseOutput.lintPartialFailures) > 0) return null;
2261
- const hasUnscannedUntrackedSourceFiles = filterSourceFiles(snapshot.untrackedFiles.map(toForwardSlashes)).some((filePath) => !analyzedHeadFiles.has(filePath));
2262
- const diagnosticDelta = computeDiagnosticDelta({
2263
- headDiagnostics: input.headDiagnostics,
2264
- baseDiagnostics: baseOutput.diagnostics,
2265
- readHeadLine: createSourceLineReader(input.directory),
2266
- readBaseLine: createSourceLineReader(snapshot.tempDirectory),
2267
- readHeadEvidence: createDiagnosticEvidenceReader(input.directory, { resolveForwardedHandlers: true }),
2268
- readBaseEvidence: createDiagnosticEvidenceReader(snapshot.tempDirectory)
2269
- });
2270
- return {
2271
- displayDiagnostics: diagnosticDelta.newDiagnostics,
2272
- baselineDelta: {
2273
- baseRef: input.baselineRef,
2274
- fixedCount: hasUnscannedUntrackedSourceFiles ? 0 : diagnosticDelta.fixedCount,
2275
- baseTotalCount: baseOutput.diagnostics.length,
2276
- crossFileMatchCount: diagnosticDelta.crossFileMatchCount
2277
- }
2278
- };
2279
- } finally {
2280
- snapshot.cleanup();
2281
- }
2282
- };
2283
- //#endregion
2284
- //#region src/cli/utils/scan-result-cache-lifecycle.ts
2285
- const createScanResultCacheLifecycle = (input) => {
2286
- const cacheKey = buildScanResultCacheKey({
2287
- projectDirectory: input.directory,
2288
- version: VERSION,
2289
- nodeBinaryPath: input.resolvedNodeBinaryPath,
2290
- options: input.options,
2291
- userConfig: input.userConfig,
2292
- hasConfigOverride: input.hasConfigOverride,
2293
- configSourceDirectory: input.configSourceDirectory,
2294
- invocationState: input.invocationState
2295
- });
2296
- const scanResultCache = cacheKey === null ? null : createScanResultCache(input.directory);
2297
- const cachedPayload = cacheKey === null || scanResultCache === null ? null : scanResultCache.lookup(cacheKey);
2298
- return {
2299
- replay: () => {
2300
- if (cachedPayload === null) return null;
2301
- recordSentryProjectContext(cachedPayload.project, input.rootSentrySpan, { concurrentScan: input.options.concurrentScan });
2302
- recordCount(METRIC.projectDetected, 1);
2303
- const isDiffMode = input.options.includePaths.length > 0;
2304
- const baselineDegraded = Boolean(input.options.baseline) && isDiffMode && cachedPayload.baselineDelta === void 0;
2305
- return renderAndRecordScan({
2306
- payload: cachedPayload,
2307
- options: input.options,
2308
- startTime: input.startTime,
2309
- rootSentrySpan: input.rootSentrySpan,
2310
- scanMode: cachedPayload.baselineDelta ? "baseline" : isDiffMode ? "diff" : "full",
2311
- baselineDegraded,
2312
- wholeRepoCacheHit: true
2313
- });
2314
- },
2315
- complete: (completion) => {
2316
- if (cacheKey !== null && scanResultCache !== null && shouldStoreScanPayload(completion.payload) && !completion.baselineDegraded) scanResultCache.store(cacheKey, completion.payload);
2317
- return renderAndRecordScan({
2318
- payload: completion.payload,
2319
- options: input.options,
2320
- startTime: input.startTime,
2321
- rootSentrySpan: input.rootSentrySpan,
2322
- scanMode: completion.scanMode,
2323
- baselineDegraded: completion.baselineDegraded,
2324
- wholeRepoCacheHit: false,
2325
- cacheStats: completion.cacheStats
2326
- });
2327
- }
2328
- };
2329
- };
2330
- //#endregion
2331
- //#region src/inspect.ts
2332
- const silentConsole = makeNoopConsole();
2333
- const inspectWithOxlintRuntime = async (directory, inputOptions, oxlintRuntime) => {
2334
- const startTime = performance.now();
2335
- const deadlineEpochMs = inputOptions.deadlineEpochMs ?? (inputOptions.maxDurationMs != null ? Date.now() + inputOptions.maxDurationMs : null);
2336
- const isConcurrentScan = inputOptions.concurrentScan === true;
2337
- if (!isConcurrentScan) resetSentryRunState();
2338
- const configOverride = inputOptions.configOverride;
2339
- const hasConfigOverride = configOverride !== void 0;
2340
- let scanDirectory;
2341
- let userConfig;
2342
- let configSourceDirectory;
2343
- if (hasConfigOverride) {
2344
- scanDirectory = directory;
2345
- userConfig = configOverride;
2346
- configSourceDirectory = inputOptions.configSourceDirectory ?? null;
2347
- } else {
2348
- const scanTarget = await resolveScanTarget(directory);
2349
- scanDirectory = scanTarget.resolvedDirectory;
2350
- userConfig = scanTarget.userConfig;
2351
- configSourceDirectory = scanTarget.configSourceDirectory;
2352
- }
2353
- const options = resolveInspectOptions(inputOptions, userConfig);
2354
- const ownsSpinnerSilence = options.silent && !isConcurrentScan;
2355
- const wasSpinnerSilent = isSpinnerSilent();
2356
- if (ownsSpinnerSilence) setSpinnerSilent(true);
2357
- try {
2358
- const result = await withSentryRunSpan(async (rootSentrySpan) => {
2359
- try {
2360
- return await runInspectWithRuntime(scanDirectory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime, deadlineEpochMs, rootSentrySpan, oxlintRuntime);
2361
- } catch (error) {
2362
- recordRunEvent(rootSentrySpan, {
2363
- ...buildRunEventConfig(options, userConfig),
2364
- mode: options.includePaths.length > 0 ? "diff" : "full",
2365
- error
2366
- });
2367
- throw error;
2368
- }
2369
- }, { concurrentScan: isConcurrentScan });
2370
- if (!isConcurrentScan) resetSentryRunState();
2371
- return result;
2372
- } finally {
2373
- if (ownsSpinnerSilence) setSpinnerSilent(wasSpinnerSilent);
2374
- }
2375
- };
2376
- const createInvocationInspect = (requestedOxlintConcurrency) => {
2377
- const concurrency = resolveScanConcurrency(requestedOxlintConcurrency ?? runSync(OxlintConcurrency));
2378
- const spawnSlots = createOxlintSpawnSlots(concurrency);
2379
- const scanResultCacheInvocationState = createScanResultCacheInvocationState();
2380
- return async (directory, inputOptions = {}) => {
2381
- const abortController = new AbortController();
2382
- const unregisterAbortController = activeScanAbortRegistry.register(abortController);
2383
- try {
2384
- return await inspectWithOxlintRuntime(directory, inputOptions, {
2385
- concurrency,
2386
- spawnSlots,
2387
- abortSignal: abortController.signal,
2388
- scanResultCacheInvocationState
2389
- });
2390
- } finally {
2391
- unregisterAbortController();
2392
- }
2393
- };
2394
- };
2395
- const inspect = async (directory, inputOptions = {}) => createInvocationInspect(inputOptions.concurrency)(directory, inputOptions);
2396
- const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime, deadlineEpochMs, rootSentrySpan, oxlintRuntime) => {
2397
- const isDiffMode = options.includePaths.length > 0;
2398
- const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly || options.silent);
2399
- const lintBindingMissing = options.lint && !resolvedNodeBinaryPath;
2400
- await yieldToEventLoop();
2401
- const scanResultCacheLifecycle = createScanResultCacheLifecycle({
2402
- directory,
2403
- options,
2404
- userConfig,
2405
- hasConfigOverride,
2406
- configSourceDirectory,
2407
- resolvedNodeBinaryPath,
2408
- invocationState: oxlintRuntime.scanResultCacheInvocationState,
2409
- startTime,
2410
- rootSentrySpan
2411
- });
2412
- const cachedResult = scanResultCacheLifecycle.replay();
2413
- if (cachedResult !== null) return cachedResult;
2414
- const shouldShowProgressSpinners = !options.isCiOrCodingAgentEnvironment && !options.silent && !options.scoreOnly && !options.suppressRendering && options.lint && Boolean(resolvedNodeBinaryPath);
2415
- const layers = buildRuntimeLayers({
2416
- directory,
2417
- hasConfigOverride,
2418
- userConfig,
2419
- configSourceDirectory,
2420
- shouldSkipLint: !options.lint || lintBindingMissing,
2421
- shouldRunDeadCode: options.deadCode,
2422
- shouldRunSupplyChain: options.supplyChain,
2423
- shouldComputeScore: !options.noScore,
2424
- shouldShowProgressSpinners,
2425
- oxlintConcurrency: oxlintRuntime.concurrency,
2426
- oxlintSpawnSlots: oxlintRuntime.spawnSlots,
2427
- reporterLayer: options.uiLayers?.reporter,
2428
- progressLayer: options.uiLayers?.progress
2429
- });
2430
- const program = runInspect({
2431
- directory,
2432
- precomputedSourceFileCount: options.precomputedSourceFileCount,
2433
- includePaths: options.includePaths,
2434
- customRulesOnly: options.customRulesOnly,
2435
- respectInlineDisables: options.respectInlineDisables,
2436
- warnings: options.warnings,
2437
- adoptExistingLintConfig: options.adoptExistingLintConfig,
2438
- ignoredTags: options.ignoredTags,
2439
- includedTags: options.includedTags,
2440
- includeTagDefaults: options.includeTagDefaults,
2441
- nodeBinaryPath: resolvedNodeBinaryPath ?? void 0,
2442
- runDeadCode: options.deadCode,
2443
- isCi: options.isCi,
2444
- doctorVersion: VERSION,
2445
- runId: getRunId(),
2446
- resolveLocalGithubViewerPermission: !options.noScore,
2447
- suppressScanSummary: options.suppressRendering,
2448
- supplyChainManifestChanged: options.supplyChainManifestChanged,
2449
- deadlineEpochMs: deadlineEpochMs ?? void 0,
2450
- signal: oxlintRuntime.abortSignal,
2451
- excludedProjectDirectories: options.excludedProjectDirectories,
2452
- retainExcludedProjectDeadCodeDiagnostics: options.retainExcludedProjectDeadCodeDiagnostics
2453
- }, { beforeLint: (projectInfo) => sync(() => {
2454
- recordSentryProjectContext(projectInfo, rootSentrySpan, { concurrentScan: options.concurrentScan });
2455
- recordCount(METRIC.projectDetected, 1);
2456
- }) });
2457
- const output = await runPromise(restoreLegacyThrow(applyObservability(options.silent ? program.pipe(provide(layers), provideService(Console, silentConsole)) : program.pipe(provide(layers)), rootSentrySpan)), { signal: oxlintRuntime.abortSignal });
2458
- const didLintFail = lintBindingMissing || output.didLintFail;
2459
- const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
2460
- if (!options.scoreOnly && !options.uiLayers && !lintBindingMissing && output.didLintFail && lintFailureReason !== null) if (output.lintFailureReasonKind === "native-binding-missing") runSync(log(highlighter.gray(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`)));
2461
- else runSync(error(highlighter.error(lintFailureReason)));
2462
- let inspectDiagnostics = output.diagnostics;
2463
- let baselineDelta;
2464
- if (options.baseline && isDiffMode && !didLintFail && countIncompleteLintFiles(output.lintPartialFailures) === 0) {
2465
- const comparison = await runBaselineComparison({
2466
- directory,
2467
- options,
2468
- userConfig,
2469
- configSourceDirectory,
2470
- headProjectInfo: output.project,
2471
- headDiagnostics: output.diagnostics,
2472
- resolvedNodeBinaryPath,
2473
- baselineRef: options.baseline.ref,
2474
- baseFiles: options.baseline.baseFiles,
2475
- headFiles: options.baseline.headFiles,
2476
- headAnalyzedFiles: output.analyzedFiles,
2477
- deadlineEpochMs,
2478
- oxlintRuntime
2479
- });
2480
- if (comparison) {
2481
- inspectDiagnostics = comparison.displayDiagnostics;
2482
- baselineDelta = comparison.baselineDelta;
2483
- }
2484
- } else if (options.changedLineRanges !== null && isDiffMode) inspectDiagnostics = filterDiagnosticsByChangedLines({
2485
- directory,
2486
- diagnostics: output.diagnostics,
2487
- changedLineRanges: options.changedLineRanges
2488
- });
2489
- const baselineDegraded = Boolean(options.baseline) && isDiffMode && baselineDelta === void 0;
2490
- const score = didLintFail ? null : output.score;
2491
- const payload = {
2492
- diagnostics: inspectDiagnostics,
2493
- score,
2494
- project: output.project,
2495
- userConfig: output.userConfig,
2496
- didLintFail,
2497
- lintFailureReason,
2498
- lintPartialFailures: output.lintPartialFailures,
2499
- didDeadCodeFail: output.didDeadCodeFail,
2500
- deadCodeFailureReason: output.deadCodeFailureReason,
2501
- deadCodeOverlapped: output.deadCodeOverlapped,
2502
- directory: output.resolvedDirectory,
2503
- scannedFileCount: output.scannedFileCount,
2504
- scannedFilePaths: output.scannedFilePaths,
2505
- analyzedFiles: output.analyzedFiles,
2506
- scanElapsedMilliseconds: output.scanElapsedMilliseconds,
2507
- scanConcurrency: output.scanConcurrency,
2508
- baselineDelta,
2509
- lintFailureReasonKind: lintBindingMissing ? "native-binding-missing" : output.lintFailureReasonKind,
2510
- supplyChainOverlapTimedOut: output.supplyChainOverlapTimedOut,
2511
- securityScanFailed: output.securityScanFailed,
2512
- securityScanFailureReason: output.securityScanFailureReason,
2513
- suppressedRuleCounts: output.suppressedRuleCounts
2514
- };
2515
- return scanResultCacheLifecycle.complete({
2516
- payload,
2517
- scanMode: baselineDelta ? "baseline" : isDiffMode ? "diff" : "full",
2518
- baselineDegraded,
2519
- cacheStats: {
2520
- lintCacheHitFileCount: output.lintCacheHitFileCount,
2521
- lintCacheTotalFileCount: output.lintCacheTotalFileCount,
2522
- lintSidecarReplayedFileCount: output.lintSidecarReplayedFileCount,
2523
- lintSidecarTotalFileCount: output.lintSidecarTotalFileCount,
2524
- deadCodeCacheHit: output.deadCodeCacheHit,
2525
- deadCodeSummaryCacheHits: output.deadCodeSummaryCacheHits,
2526
- deadCodeSummaryCacheMisses: output.deadCodeSummaryCacheMisses
2527
- }
2528
- });
2529
- };
2530
- //#endregion
2531
- //#region src/cli/utils/deduplicate-project-scans.ts
2532
- const deduplicateProjectScans = (projectScans) => {
2533
- const uniqueProjectScans = [];
2534
- const seenDirectories = /* @__PURE__ */ new Set();
2535
- for (const projectScan of projectScans) {
2536
- if (seenDirectories.has(projectScan.directory)) continue;
2537
- seenDirectories.add(projectScan.directory);
2538
- uniqueProjectScans.push(projectScan);
2539
- }
2540
- return uniqueProjectScans;
2541
- };
2542
- //#endregion
2543
- //#region src/cli/utils/collect-project-source-file-counts.ts
2544
- const collectProjectSourceFileCounts = async (rootDirectory, projectDirectories) => {
2545
- const abortController = new AbortController();
2546
- const unregisterAbortController = activeScanAbortRegistry.register(abortController);
2547
- try {
2548
- return await collectSourceFileCountsByDirectory(rootDirectory, projectDirectories, abortController.signal);
2549
- } finally {
2550
- unregisterAbortController();
2551
- }
2552
- };
2553
- //#endregion
2554
- //#region src/cli/utils/build-handoff-payload.ts
2555
- const buildHandoffPayload = (input) => {
2556
- const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, 3);
2557
- const migrationScaleBuckets = new Map(findMigrationScaleBuckets(input.diagnostics).map((bucket) => [bucket.ruleKey, bucket]));
2558
- let outputDirectory = null;
2559
- try {
2560
- outputDirectory = writeDiagnosticsDirectory([...input.diagnostics], input.outputDirectory);
2561
- } catch {}
2562
- const lines = [`Review and fix the top ${topGroups.length} React Doctor ${topGroups.length === 1 ? "finding" : "findings"} in ${input.projectName}. Leave the rest for a follow-up.`, ""];
2563
- topGroups.forEach(([ruleKey, ruleDiagnostics], index) => {
2564
- const representative = ruleDiagnostics[0];
2565
- const severityLabel = representative.severity === "error" ? "ERROR" : "WARN";
2566
- const sharedFixSiteCount = getSharedFixSiteCount(ruleDiagnostics);
2567
- const countBadge = sharedFixSiteCount > 0 ? `one fix · ${sharedFixSiteCount} sites` : `×${ruleDiagnostics.length}`;
2568
- lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (${countBadge})`, ` ${representative.message}`);
2569
- const fixRecipeLine = formatFixRecipeLine(representative);
2570
- if (fixRecipeLine) lines.push(` ${fixRecipeLine}`);
2571
- const uniqueFiles = [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath))];
2572
- for (const filePath of uniqueFiles.slice(0, 3)) {
2573
- const firstSite = ruleDiagnostics.find((diagnostic) => diagnostic.filePath === filePath && diagnostic.line > 0);
2574
- lines.push(` - ${filePath}${firstSite ? `:${firstSite.line}` : ""}`);
2575
- }
2576
- const remainingFiles = uniqueFiles.length - 3;
2577
- if (remainingFiles > 0) lines.push(` - +${remainingFiles} more files`);
2578
- const migrationBucket = migrationScaleBuckets.get(ruleKey);
2579
- if (migrationBucket) lines.push(` Migration-scale (${migrationBucket.fileCount} files): fix a representative sample, confirm the recipe holds, and get the code owner's sign-off before changing the rest in one pass.`);
2580
- });
2581
- lines.push("");
2582
- if (outputDirectory) lines.push(`Full results for all ${input.diagnostics.length} issues (diagnostics.json + a .txt per rule): ${outputDirectory}`, "");
2583
- lines.push("Read each file and fix the root cause — don't suppress or silence the rule.", "", "Findings that share a `fixGroupId` (in diagnostics.json) are one root cause — a single fix clears all of them, so treat each `fixGroupId` as ONE task, not one per site.", "", "Verify against the real thing, don't assume: confirm each change matches the canonical fix recipe you fetched for that rule, then re-run `npx react-doctor@latest --verbose` and check the issue is actually gone against the real tool before moving on.", "", "Teach me as you go: for every issue you touch, explain it in plain language (no jargon) — what the problem is, why it's a problem, and how serious it is in human terms. Describe the real-world impact and severity concretely (e.g. \"this crashes the page for users on Safari\" vs. \"this is a minor cleanup with no user impact\") so I understand why it matters, not just what changed.", "");
2584
- const shownRuleKeys = new Set(topGroups.map(([ruleKey]) => ruleKey));
2585
- const deferredMigrationBuckets = [...migrationScaleBuckets.values()].filter((bucket) => !shownRuleKeys.has(bucket.ruleKey));
2586
- if (deferredMigrationBuckets.length > 0) {
2587
- const ruleSummaries = deferredMigrationBuckets.map((bucket) => `${bucket.title} (${bucket.fileCount} files)`).join(", ");
2588
- lines.push(`Some of the rest are migration-scale (span dozens of files): ${ruleSummaries}. For each, fix a representative sample, confirm the recipe holds, and get the code owner's sign-off before changing the rest in one pass.`, "");
2589
- }
2590
- lines.push("Stop after this pass. Summarize the remaining findings for a follow-up.");
2591
- return lines.join("\n");
2592
- };
2593
- //#endregion
2594
- //#region src/cli/utils/launch-agent.ts
2595
- const isWindows = process.platform === "win32";
2596
- const CLI_AGENT_IDS = [
2597
- "claude-code",
2598
- "codex",
2599
- "cursor"
2600
- ];
2601
- const CLI_AGENT_BINARIES = {
2602
- "claude-code": "claude",
2603
- codex: "codex",
2604
- cursor: "cursor-agent"
2605
- };
2606
- const CLI_AGENT_AUTO_FLAGS = {
2607
- "claude-code": ["--dangerously-skip-permissions"],
2608
- codex: ["--yolo"],
2609
- cursor: ["--force"]
2610
- };
2611
- const resolveCursorBundledNode = () => {
2612
- if (!isWindows) return null;
2613
- const localAppData = process.env.LOCALAPPDATA;
2614
- if (!localAppData) return null;
2615
- const cursorAgentRoot = path$1.join(localAppData, "cursor-agent", "versions");
2616
- try {
2617
- const versions = fs$1.readdirSync(cursorAgentRoot);
2618
- if (versions.length === 0) return null;
2619
- const sortedVersions = versions.sort((a, b) => b.localeCompare(a, void 0, { numeric: true }));
2620
- for (const version of sortedVersions) try {
2621
- const bundledNodePath = path$1.join(cursorAgentRoot, version, "node.exe");
2622
- const entryScriptPath = path$1.join(cursorAgentRoot, version, "index.js");
2623
- if (fs$1.statSync(bundledNodePath).isFile() && fs$1.statSync(entryScriptPath).isFile()) return {
2624
- command: bundledNodePath,
2625
- args: [entryScriptPath]
2626
- };
2627
- } catch {}
2628
- } catch {}
2629
- return null;
2630
- };
2631
- const resolveWindowsCmdEntryScript = (command) => {
2632
- if (!isWindows) return null;
2633
- const pathDirectories = (process.env.PATH ?? "").split(path$1.delimiter).filter(Boolean);
2634
- for (const directory of pathDirectories) {
2635
- const cmdFilePath = path$1.join(directory, `${command}.cmd`);
2636
- try {
2637
- if (!fs$1.statSync(cmdFilePath).isFile()) continue;
2638
- } catch {
2639
- continue;
2640
- }
2641
- try {
2642
- const entryScriptMatch = fs$1.readFileSync(cmdFilePath, "utf8").match(/"%(?:~dp0|dp0%)\\([^"]+\.(?:m?js|cjs))"/);
2643
- if (!entryScriptMatch) continue;
2644
- const resolvedScriptPath = path$1.resolve(directory, entryScriptMatch[1]);
2645
- if (fs$1.statSync(resolvedScriptPath).isFile()) return resolvedScriptPath;
2646
- } catch {}
2647
- }
2648
- return null;
2649
- };
2650
- const spawnAgent = (command, args, cwd) => new Promise((resolve, reject) => {
2651
- const child = spawn(command, [...args], {
2652
- cwd,
2653
- stdio: "inherit"
2654
- });
2655
- child.on("error", reject);
2656
- child.on("close", (code) => resolve(code ?? 0));
2657
- });
2658
- const launchCliAgent = async (agentId, prompt, cwd) => {
2659
- const binary = CLI_AGENT_BINARIES[agentId];
2660
- const agentArgs = [...CLI_AGENT_AUTO_FLAGS[agentId], prompt];
2661
- if (isWindows) {
2662
- if (agentId === "cursor") {
2663
- const cursorResolution = resolveCursorBundledNode();
2664
- if (cursorResolution) return spawnAgent(cursorResolution.command, [...cursorResolution.args, ...agentArgs], cwd);
2665
- }
2666
- const entryScript = resolveWindowsCmdEntryScript(binary);
2667
- if (entryScript) return spawnAgent(process.execPath, [entryScript, ...agentArgs], cwd);
2668
- }
2669
- try {
2670
- return await spawnAgent(binary, agentArgs, cwd);
2671
- } catch {
2672
- throw new Error(`Failed to launch ${binary}`);
2673
- }
2674
- };
2675
- const CLIPBOARD_COMMANDS = [
2676
- {
2677
- binary: "pbcopy",
2678
- args: []
2679
- },
2680
- {
2681
- binary: "wl-copy",
2682
- args: []
2683
- },
2684
- {
2685
- binary: "xclip",
2686
- args: ["-selection", "clipboard"]
2687
- },
2688
- {
2689
- binary: "xsel",
2690
- args: ["--clipboard", "--input"]
2691
- },
2692
- {
2693
- binary: "clip",
2694
- args: []
2695
- }
2696
- ];
2697
- const copyToClipboard = (text) => {
2698
- const command = CLIPBOARD_COMMANDS.find((candidate) => isCommandAvailable(candidate.binary));
2699
- if (!command) return Promise.resolve(false);
2700
- return new Promise((resolve) => {
2701
- const child = spawn(command.binary, command.args);
2702
- child.on("error", () => resolve(false));
2703
- child.on("close", (code) => resolve(code === 0));
2704
- child.stdin.on("error", () => resolve(false));
2705
- child.stdin.end(text);
2706
- });
2707
- };
2708
- //#endregion
2709
- //#region src/cli/utils/detect-launchable-agents.ts
2710
- const detectLaunchableAgents = async () => {
2711
- const detected = new Set(await detectAvailableAgents());
2712
- return CLI_AGENT_IDS.filter((agentId) => detected.has(agentId) && isCommandAvailable(CLI_AGENT_BINARIES[agentId]));
2713
- };
2714
- //#endregion
2715
- //#region src/cli/utils/set-up-github-actions.ts
2716
- const setUpGitHubActions = async (options) => {
2717
- const projectRoot = findNearestPackageDirectory(options.rootDirectory) ?? options.rootDirectory;
2718
- try {
2719
- installReactDoctorScriptStep(projectRoot);
2720
- } catch {}
2721
- const workflowSpinner = spinner("Adding GitHub Actions workflow...").start();
2722
- const defaultBranch = await detectDefaultBranch(projectRoot) ?? "main";
2723
- const workflowResult = installReactDoctorWorkflow(projectRoot, defaultBranch);
2724
- const didCreateWorkflow = reportWorkflowResult(workflowSpinner, workflowResult, projectRoot);
2725
- cliLogger.break();
2726
- if (workflowResult.status === "failed") {
2727
- cliLogger.log(` Couldn't set up GitHub Actions automatically. Follow the guide at ${highlighter.info(CI_URL)}`);
2728
- return false;
2729
- }
2730
- if (workflowResult.status === "created") {
2731
- const pullRequestSpinner = spinner("Opening a pull request for review...").start();
2732
- const pullRequestResult = await openWorkflowPullRequest({
2733
- workflowPath: workflowResult.workflowPath,
2734
- baseBranch: defaultBranch
2735
- });
2736
- if (pullRequestResult.status === "pr-opened") pullRequestSpinner.succeed(`Opened pull request for review: ${highlighter.info(pullRequestResult.url)}`);
2737
- else if (pullRequestResult.status === "pr-exists") pullRequestSpinner.succeed(`A React Doctor setup pull request is already open: ${highlighter.info(pullRequestResult.url)}`);
2738
- else if (pullRequestResult.status === "branch-pushed") pullRequestSpinner.warn(`Pushed branch ${highlighter.bold(pullRequestResult.branch)} but couldn't open a PR. Open one with: gh pr create --head ${pullRequestResult.branch}`);
2739
- else {
2740
- pullRequestSpinner.stop();
2741
- const didStage = await stageWorkflowFile({ workflowPath: workflowResult.workflowPath });
2742
- cliLogger.log(didStage ? ` Staged the workflow file. Commit it to start scanning every pull request.` : " React Doctor will now scan every new pull request automatically.");
2743
- }
2744
- }
2745
- cliLogger.log(` Learn more: ${highlighter.info(CI_URL)}`);
2746
- return didCreateWorkflow;
2747
- };
2748
- //#endregion
2749
- //#region src/cli/utils/count-unique-scanned-files.ts
2750
- const countUniqueScannedFiles = (results) => {
2751
- const uniqueScannedFilePaths = /* @__PURE__ */ new Set();
2752
- let fileCountFromScansWithoutPaths = 0;
2753
- for (const result of results) {
2754
- const scannedFilePaths = result.scannedFilePaths;
2755
- if (scannedFilePaths && scannedFilePaths.length > 0) for (const filePath of scannedFilePaths) uniqueScannedFilePaths.add(filePath);
2756
- else fileCountFromScansWithoutPaths += result.scannedFileCount ?? result.project.sourceFileCount;
2757
- }
2758
- return uniqueScannedFilePaths.size + fileCountFromScansWithoutPaths;
2759
- };
2760
- //#endregion
2761
- //#region src/cli/utils/filter-scans-for-surface.ts
2762
- const filterScansForSurface = (completedScans, surface) => completedScans.flatMap((scan) => filterDiagnosticsForSurface([...scan.result.diagnostics], surface, scan.config));
2763
- //#endregion
2764
- //#region src/cli/utils/resolve-max-duration-flag.ts
2765
- const resolveMaxDurationFlag = (maxDuration) => {
2766
- if (maxDuration === void 0) return void 0;
2767
- const seconds = Number(maxDuration);
2768
- if (!Number.isFinite(seconds) || seconds <= 0) {
2769
- cliLogger.warn(`Invalid --max-duration "${maxDuration}". Expected a positive number of seconds; running without a budget.`);
2770
- return;
2771
- }
2772
- return seconds * MILLISECONDS_PER_SECOND;
2773
- };
2774
- //#endregion
2775
- //#region src/cli/utils/resolve-parallel-flag.ts
2776
- /**
2777
- * Maps the `--no-parallel` flag to an `InspectOptions.concurrency` value.
2778
- * `--no-parallel` (`parallel === false`) pins serial, overriding any
2779
- * env-enabled default; otherwise `undefined` defers to the ambient
2780
- * `OxlintConcurrency` default (parallel, unless `REACT_DOCTOR_PARALLEL`
2781
- * sets a worker count — the flag itself no longer takes one).
2782
- */
2783
- const resolveParallelFlag = (parallel) => parallel === false ? 1 : void 0;
2784
- //#endregion
2785
- //#region src/cli/utils/resolve-cli-inspect-options.ts
2786
- /**
2787
- * Translates CLI flags into the `InspectOptions` contract `inspect()`
2788
- * accepts. Flag-derived fields only (`scoreOnly`, `noScore`, `silent`,
2789
- * `isCi`): every `userConfig` fallback — including `noScore` — lives in
2790
- * `inspect()`'s merge layer, which sees each project's module-merged
2791
- * config. The plain boolean knobs (`lint`, `deadCode`, `supplyChain`,
2792
- * `verbose`) pass through unchanged for the same reason. This resolver reads
2793
- * `userConfig` only to decide the `--blocking warning` gate.
2794
- */
2795
- const resolveCliInspectOptions = (flags, userConfig) => {
2796
- const wantsWarningGate = pickBlockingLevel(flags, userConfig) === "warning";
2797
- const isDesignScan = flags.design === true;
2798
- return {
2799
- lint: flags.lint,
2800
- deadCode: isDesignScan ? false : flags.deadCode,
2801
- supplyChain: isDesignScan ? false : flags.supplyChain,
2802
- verbose: flags.verbose,
2803
- outputDirectory: flags.outputDir,
2804
- respectInlineDisables: flags.respectInlineDisables === false ? false : void 0,
2805
- warnings: wantsWarningGate ? true : flags.warnings,
2806
- scoreOnly: flags.score === true,
2807
- noScore: isDesignScan || flags.score === false || flags.telemetry === false ? true : void 0,
2808
- isCi: isCiEnvironment(),
2809
- silent: Boolean(flags.json),
2810
- concurrency: resolveParallelFlag(flags.parallel),
2811
- maxDurationMs: resolveMaxDurationFlag(flags.maxDuration),
2812
- categoryFilters: resolveCliCategories(flags.category),
2813
- includedTags: isDesignScan ? new Set(["design"]) : void 0,
2814
- includeTagDefaults: isDesignScan ? true : void 0,
2815
- scoreDisabledMessage: isDesignScan ? "Design scans do not affect the React health score." : void 0
2816
- };
2817
- };
2818
- //#endregion
2819
- //#region src/cli/utils/resolve-workspace-dead-code-owner.ts
2820
- const resolveWorkspaceDeadCodeOwner = (input) => {
2821
- if (!input.isRootDeadCodeEnabled) return null;
2822
- const resolvedRootDirectory = path$1.resolve(input.rootDirectory);
2823
- return input.projectDirectories.find((projectDirectory) => path$1.resolve(projectDirectory) === resolvedRootDirectory) ?? null;
2824
- };
2825
- //#endregion
2826
- //#region src/cli/utils/retry-missing-project-scores.ts
2827
- const retryMissingProjectScores = async (projectScans) => mapWithConcurrency(projectScans, 64, async (projectScan) => {
2828
- if (!(projectScan.result.score === null && !projectScan.isScoreDisabled && !hasIncompleteScoreAnalysis(projectScan.result.skippedChecks))) return projectScan;
2829
- const score = await calculateScore(filterScansForSurface([projectScan], "score"));
2830
- recordCount(METRIC.scanScoreRetry, 1, { succeeded: score !== null });
2831
- return score === null ? projectScan : {
2832
- ...projectScan,
2833
- result: {
2834
- ...projectScan.result,
2835
- score
2836
- }
2837
- };
2838
- });
2839
- //#endregion
2840
- //#region src/cli/utils/resolve-project-relative-directory.ts
2841
- /**
2842
- * The scanned project's directory relative to the diff root, forward-slashed —
2843
- * or `null` when the project resolves outside the root (a `..` segment or an
2844
- * absolute path, neither of which can be matched against repo-relative changed
2845
- * files). An empty string means the project *is* the diff root. Shared by the
2846
- * diff helpers that map changed files to a workspace (`resolveProjectDiffIncludePaths`,
2847
- * `projectManifestChanged`) so the boundary rule lives in one place.
2848
- */
2849
- const resolveProjectRelativeDirectory = (rootDirectory, projectDirectory) => {
2850
- const relativeProjectDirectory = toForwardSlashes(path$1.relative(rootDirectory, projectDirectory));
2851
- if (relativeProjectDirectory === ".." || relativeProjectDirectory.startsWith("../")) return null;
2852
- if (path$1.isAbsolute(relativeProjectDirectory)) return null;
2853
- return relativeProjectDirectory;
2854
- };
2855
- //#endregion
2856
- //#region src/cli/utils/resolve-project-source-file-paths.ts
2857
- const resolveProjectSourceFilePaths = (rootDirectory, projectDirectory, filePaths) => {
2858
- const relativeProjectDirectory = resolveProjectRelativeDirectory(rootDirectory, projectDirectory);
2859
- if (relativeProjectDirectory === null) return [];
2860
- const sourceFilePaths = filterSourceFiles([...filePaths]);
2861
- if (relativeProjectDirectory.length === 0) return sourceFilePaths;
2862
- const projectPrefix = `${relativeProjectDirectory}/`;
2863
- return sourceFilePaths.flatMap((filePath) => {
2864
- const normalizedFilePath = toForwardSlashes(filePath);
2865
- if (!normalizedFilePath.startsWith(projectPrefix)) return [];
2866
- const projectRelativePath = normalizedFilePath.slice(projectPrefix.length);
2867
- return projectRelativePath.length > 0 ? [projectRelativePath] : [];
2868
- });
2869
- };
2870
- //#endregion
2871
- //#region src/cli/utils/resolve-project-diff-include-paths.ts
2872
- const resolveProjectDiffIncludePaths = (rootDirectory, projectDirectory, diffInfo) => {
2873
- return resolveProjectSourceFilePaths(rootDirectory, projectDirectory, diffInfo.changedFiles);
2874
- };
2875
- /**
2876
- * Re-keys repo-relative changed line ranges (from `getChangedLineRanges`) to
2877
- * the scanned project, mirroring `resolveProjectDiffIncludePaths`'s boundary
2878
- * rule so the `lines` scope filter inside `inspect()` matches the project-
2879
- * relative diagnostic paths. Files outside the project are dropped.
2880
- */
2881
- const resolveProjectChangedLineRanges = (rootDirectory, projectDirectory, ranges) => {
2882
- const relativeProjectDirectory = resolveProjectRelativeDirectory(rootDirectory, projectDirectory);
2883
- if (relativeProjectDirectory === null) return [];
2884
- if (relativeProjectDirectory.length === 0) return ranges.map((entry) => ({
2885
- file: toForwardSlashes(entry.file),
2886
- ranges: entry.ranges
2887
- }));
2888
- const projectPrefix = `${relativeProjectDirectory}/`;
2889
- return ranges.flatMap((entry) => {
2890
- const normalizedFilePath = toForwardSlashes(entry.file);
2891
- if (!normalizedFilePath.startsWith(projectPrefix)) return [];
2892
- const projectRelativePath = normalizedFilePath.slice(projectPrefix.length);
2893
- return projectRelativePath.length > 0 ? [{
2894
- file: projectRelativePath,
2895
- ranges: entry.ranges
2896
- }] : [];
2897
- });
2898
- };
2899
- //#endregion
2900
- //#region src/cli/utils/resolve-project-scan.ts
2901
- const resolveProjectScan = async (rootScanTarget, projectDirectory) => {
2902
- const projectScanTarget = projectDirectory === rootScanTarget.resolvedDirectory ? rootScanTarget : await resolveScanTarget(projectDirectory, { allowAmbiguous: true });
2903
- return {
2904
- directory: projectScanTarget.resolvedDirectory,
2905
- config: projectDirectory === rootScanTarget.resolvedDirectory ? rootScanTarget.userConfig : mergeReactDoctorConfigs(rootScanTarget.userConfig, projectScanTarget.userConfig ?? void 0),
2906
- configSourceDirectory: projectScanTarget.userConfig?.plugins === void 0 ? rootScanTarget.configSourceDirectory : projectScanTarget.configSourceDirectory
2907
- };
2908
- };
2909
- //#endregion
2910
- //#region src/cli/utils/resolve-absolute-path.ts
2911
- /**
2912
- * Resolves a diagnostic's `filePath` (relative to its project root, or
2913
- * already absolute) to an absolute path. Shared by the code-frame reader and
2914
- * the terminal hyperlink builder so both turn a relative path into the same
2915
- * on-disk location.
2916
- */
2917
- const resolveAbsolutePath = (filePath, rootDirectory) => path$1.isAbsolute(filePath) ? filePath : path$1.resolve(rootDirectory || ".", filePath);
2918
- //#endregion
2919
- //#region src/cli/utils/build-code-frame.ts
2920
- /**
2921
- * Renders a syntax-highlighted source excerpt around a diagnostic site
2922
- * with a caret pointing at the offending column. Returns null when the
2923
- * file can't be read (e.g. multi-project summaries where paths are
2924
- * resolved against a different cwd), so callers can fall back to the
2925
- * bare `file:line` reference instead of failing the whole render.
2926
- */
2927
- const buildCodeFrame = (input) => {
2928
- if (input.line <= 0) return null;
2929
- const absolutePath = resolveAbsolutePath(input.filePath, input.rootDirectory);
2930
- let source;
2931
- try {
2932
- source = fs$1.readFileSync(absolutePath, "utf8");
2933
- } catch {
2934
- return null;
2935
- }
2936
- if ((source.split("\n", input.line)[input.line - 1] ?? "").length > 200) return null;
2937
- const location = input.endLine != null && input.endLine > input.line ? {
2938
- start: { line: input.line },
2939
- end: { line: input.endLine }
2940
- } : { start: {
2941
- line: input.line,
2942
- column: input.column > 0 ? input.column : void 0
2943
- } };
2944
- return codeFrameColumns(source, location, {
2945
- highlightCode: true,
2946
- linesAbove: 1,
2947
- linesBelow: 1,
2948
- ...input.message ? { message: input.message } : {}
2949
- });
2950
- };
2951
- //#endregion
2952
- //#region src/cli/utils/select-projects.ts
2953
- const discoverWorkspacePackages = (rootDirectory) => {
2954
- const hasRootPackageJson = isFile(path$1.join(rootDirectory, "package.json"));
2955
- const packages = listWorkspacePackages(rootDirectory);
2956
- if (packages.length === 0 && (!hasRootPackageJson || isMonorepoRoot(rootDirectory))) return discoverReactSubprojects(rootDirectory);
2957
- return packages;
2958
- };
2959
- const selectProjects = async (rootDirectory, projectFlag, skipPrompts, configProjects) => {
2960
- const packages = discoverWorkspacePackages(rootDirectory);
2961
- if (projectFlag) return resolveProjectFlag(projectFlag, packages, rootDirectory);
2962
- const configRequestedNames = (configProjects ?? []).map((requestedName) => requestedName.trim()).filter((requestedName) => requestedName.length > 0);
2963
- if (configRequestedNames.length > 0) {
2964
- const resolvedDirectories = resolveRequestedProjects(configRequestedNames, packages, rootDirectory, "config");
2965
- recordCount(METRIC.projectConfigSelected, resolvedDirectories.length);
2966
- return resolvedDirectories;
2967
- }
2968
- if (packages.length === 0) return [rootDirectory];
2969
- if (packages.length === 1) {
2970
- cliLogger.log(`${highlighter.success("✔")} Select projects ${highlighter.dim("›")} ${packages[0].name}`);
2971
- return [packages[0].directory];
2972
- }
2973
- if (skipPrompts) {
2974
- printDiscoveredProjects(packages);
2975
- return packages.map((workspacePackage) => workspacePackage.directory);
2976
- }
2977
- return promptProjectSelection(packages, rootDirectory);
2978
- };
2979
- const ALL_PROJECTS_SENTINEL = "*";
2980
- const resolveProjectFlag = (projectFlag, workspacePackages, rootDirectory) => {
2981
- const requestedNames = projectFlag.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
2982
- if (requestedNames.length === 0) throw new CliInputError(`--project "${projectFlag}" did not name any project. Pass a project name, a comma-separated list, or "*" for all.`);
2983
- return resolveRequestedProjects(requestedNames, workspacePackages, rootDirectory, "flag");
2984
- };
2985
- const resolveRequestedProjects = (requestedNames, workspacePackages, rootDirectory, source) => {
2986
- if (requestedNames.includes(ALL_PROJECTS_SENTINEL)) return workspacePackages.length > 0 ? workspacePackages.map((workspacePackage) => workspacePackage.directory) : [rootDirectory];
2987
- const sourceLabel = source === "flag" ? "Project" : "Config \"projects\" entry";
2988
- return requestedNames.map((requestedName) => {
2989
- const matched = workspacePackages.find((workspacePackage) => workspacePackage.name === requestedName || path$1.basename(workspacePackage.directory) === requestedName);
2990
- if (matched) return matched.directory;
2991
- if (path$1.basename(rootDirectory) === requestedName) return rootDirectory;
2992
- const candidateDirectory = path$1.resolve(rootDirectory, requestedName);
2993
- if (isDirectory(candidateDirectory)) {
2994
- recordCount(METRIC.projectPathSelected);
2995
- return candidateDirectory;
2996
- }
2997
- const availableNames = workspacePackages.map((workspacePackage) => workspacePackage.name).join(", ");
2998
- throw new CliInputError(workspacePackages.length > 0 ? `${sourceLabel} "${requestedName}" is not a workspace project or a directory. Available projects: ${availableNames}` : `${sourceLabel} "${requestedName}" is not a directory under ${rootDirectory}.`);
2999
- });
3000
- };
3001
- const printDiscoveredProjects = (packages) => {
3002
- cliLogger.log(`${highlighter.success("✔")} Select projects ${highlighter.dim("›")} ${packages.map((workspacePackage) => workspacePackage.name).join(", ")}`);
3003
- };
3004
- const promptProjectSelection = async (workspacePackages, rootDirectory) => {
3005
- const { selectedDirectories } = await prompts({
3006
- type: "multiselect",
3007
- name: "selectedDirectories",
3008
- message: "Select projects",
3009
- choices: workspacePackages.map((workspacePackage) => ({
3010
- title: workspacePackage.name,
3011
- description: path$1.relative(rootDirectory, workspacePackage.directory),
3012
- value: workspacePackage.directory
3013
- })),
3014
- min: 1
3015
- });
3016
- return selectedDirectories;
3017
- };
3018
- //#endregion
3019
- //#region src/cli/utils/project-scan-outcome.ts
3020
- const partitionProjectScanOutcomes = (outcomes) => ({
3021
- completedScans: outcomes.flatMap((outcome) => outcome.status === "completed" ? [outcome.value] : []),
3022
- skippedScans: outcomes.flatMap((outcome) => outcome.status === "skipped" ? [outcome.value] : [])
3023
- });
3024
- //#endregion
3025
- //#region src/cli/utils/project-manifest-changed.ts
3026
- const PACKAGE_JSON = "package.json";
3027
- /**
3028
- * True when the scanned project's own `package.json` is among the diff's
3029
- * changed files. Shares `resolveProjectRelativeDirectory`'s boundary handling
3030
- * with `resolveProjectDiffIncludePaths` so a workspace package matches only
3031
- * its own manifest (not the monorepo root's or a sibling's) — which is exactly
3032
- * what that project's per-project supply-chain check scores.
3033
- */
3034
- const projectManifestChanged = (rootDirectory, projectDirectory, diffInfo) => {
3035
- const relativeProjectDirectory = resolveProjectRelativeDirectory(rootDirectory, projectDirectory);
3036
- if (relativeProjectDirectory === null) return false;
3037
- const manifestPath = relativeProjectDirectory.length === 0 ? PACKAGE_JSON : `${relativeProjectDirectory}/${PACKAGE_JSON}`;
3038
- return diffInfo.changedFiles.some((filePath) => toForwardSlashes(filePath) === manifestPath);
3039
- };
3040
- //#endregion
3041
- //#region src/cli/utils/validate-mode-flags.ts
3042
- const usedDiffAlias = (flags) => flags.diff !== void 0 && flags.diff !== false && flags.diff !== "false" && flags.diff !== "";
3043
- const usedScope = (flags) => typeof flags.scope === "string" && flags.scope.length > 0;
3044
- const validateIncludeUntrackedScope = (includeUntracked, scope) => {
3045
- if (!includeUntracked || scope !== void 0 && scope !== "full") return;
3046
- throw new CliInputError("--include-untracked requires the files, changed, or lines scope (via --scope, --diff, or config).");
3047
- };
3048
- const validateModeFlags = (flags) => {
3049
- if (usedScope(flags) && usedDiffAlias(flags)) throw new CliInputError("Cannot combine --scope and --diff; --diff is the deprecated alias.");
3050
- if (flags.staged && usedDiffAlias(flags)) throw new CliInputError("Cannot combine --staged and --diff; pick one mode.");
3051
- if (flags.staged && (flags.scope === "full" || flags.scope === "changed")) throw new CliInputError(`Cannot combine --staged with --scope ${flags.scope}; use --scope files or --scope lines, or drop --scope.`);
3052
- if (flags.includeUntracked && flags.staged) throw new CliInputError("Cannot combine --include-untracked with --staged; the git index never holds untracked files.");
3053
- if (flags.score && flags.json) throw new CliInputError("Cannot combine --score and --json; pick one output mode.");
3054
- if (flags.score && flags.telemetry === false) throw new CliInputError("Cannot combine --score with --no-telemetry; --score prints the score that --no-telemetry disables.");
3055
- if (flags.debug && (flags.score === false || flags.telemetry === false)) {
3056
- const disablingFlag = flags.score === false ? "--no-score" : "--no-telemetry";
3057
- throw new CliInputError(`Cannot combine --debug with ${disablingFlag}; ${disablingFlag} disables the Sentry reporting --debug needs to capture a trace.`);
3058
- }
3059
- };
3060
- //#endregion
3061
- //#region src/cli/utils/format-skipped-projects-message.ts
3062
- const formatSkippedProjectsMessage = (projectCount) => `${projectCount} ${projectCount === 1 ? "project was" : "projects were"} skipped because the max scan duration was reached.`;
3063
- //#endregion
3064
- //#region src/cli/utils/should-fail-scan-gate.ts
3065
- const shouldFailScanGate = (input) => {
3066
- if (input.blockingLevel === "none") return false;
3067
- if (input.scans.some(({ result }) => hasLintHardFailure(result))) return true;
3068
- if (input.diagnosticsAreGateExempt === true) return false;
3069
- return shouldBlockCi(filterScansForSurface(input.scans, "ciFailure"), input.blockingLevel);
3070
- };
3071
- //#endregion
3072
- export { resolveMergeBaseRef as A, buildEmptyReportMessage as B, copyToClipboard as C, deduplicateProjectScans as D, collectProjectSourceFileCounts as E, printDiagnosticsDump as F, formatLearnMoreLine as G, buildRulePriorityMap as H, formatDiagnosticSite as I, hasIncompleteScoreAnalysis as L, pluralize as M, formatElapsedTime as N, createInvocationInspect as O, printFooter as P, filterDiagnosticsByCategories as R, CLI_AGENT_BINARIES as S, buildHandoffPayload as T, buildSortedRuleGroups as U, hasLintHardFailure as V, formatFixRecipeLine as W, resolveCliInspectOptions as _, projectManifestChanged as a, setUpGitHubActions as b, selectProjects as c, resolveProjectChangedLineRanges as d, resolveProjectDiffIncludePaths as f, resolveWorkspaceDeadCodeOwner as g, retryMissingProjectScores as h, validateModeFlags as i, printHeadlessReport as j, inspect as k, buildCodeFrame as l, resolveProjectRelativeDirectory as m, formatSkippedProjectsMessage as n, partitionProjectScanOutcomes as o, resolveProjectSourceFilePaths as p, validateIncludeUntrackedScope as r, discoverWorkspacePackages as s, shouldFailScanGate as t, resolveProjectScan as u, filterScansForSurface as v, launchCliAgent as w, detectLaunchableAgents as x, countUniqueScannedFiles as y, buildNoScoreMessage as z };
3073
-
3074
- //# sourceMappingURL=should-fail-scan-gate-DImKUuu-.js.map
3075
- //# debugId=3db0a4af-3830-56c7-a8d6-a6f446bd2cf2