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.
- package/dist/cli.js +10780 -96
- package/dist/{devtools-DPRVRu3S.js → devtools-BQATi33M.js} +3 -3
- package/dist/{record-metric-CtlQma6P.js → exit-gracefully-BJRsqdaK.js} +11954 -6251
- package/dist/index.d.ts +4082 -4704
- package/dist/index.js +4386 -6069
- package/dist/lsp.js +4510 -6181
- package/dist/{run-scan-app-Drlqxtzl.js → run-scan-app-DawD5rhU.js} +29 -36
- package/package.json +3 -2
- package/dist/build-section-divider-QykRnrlR.js +0 -312
- package/dist/ci-BvGqcDKc.js +0 -7501
- package/dist/design-SGX69YQq.js +0 -14
- package/dist/exit-gracefully-CddufLS0.js +0 -47
- package/dist/inspect-DBJtHure.js +0 -1443
- package/dist/install-doctor-script-DxrteuEw.js +0 -3251
- package/dist/install-ffJ108LC.js +0 -32
- package/dist/install-github-workflow-BP6t3YBA.js +0 -267
- package/dist/install-react-doctor-BhfvyNZB.js +0 -1154
- package/dist/print-branded-header-Cw5AlicW.js +0 -22
- package/dist/read-object-file-ITGVjFKp.js +0 -24
- package/dist/report-error-DUoFKR7d.js +0 -158
- package/dist/report-workflow-result-D3GCowCN.js +0 -279
- package/dist/resolve-scope-gy72JH3h.js +0 -339
- package/dist/rules-6-d19pN1.js +0 -581
- package/dist/scan-C3Io41Dq.js +0 -53
- package/dist/should-fail-scan-gate-DImKUuu-.js +0 -3075
- package/dist/should-skip-prompts-CbIIaU8b.js +0 -10
- package/dist/unref-stdin-B5RnVeLa.js +0 -120
- package/dist/version-BFKBxkKV.js +0 -21
- package/dist/why-DO4CTn-R.js +0 -21
|
@@ -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
|