| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | */ |
| 7 | |
| 8 | import type {Tracker} from 'isl-server/src/analytics/tracker'; |
| 9 | import type {UseUncommittedSelection} from './partialSelection'; |
| 10 | import type {CommitInfo, Diagnostic, DiagnosticAllowlist} from './types'; |
| 11 | |
| 12 | import * as stylex from '@stylexjs/stylex'; |
| 13 | import {Checkbox} from 'isl-components/Checkbox'; |
| 14 | import {Column, Row} from 'isl-components/Flex'; |
| 15 | import {Icon} from 'isl-components/Icon'; |
| 16 | import {Subtle} from 'isl-components/Subtle'; |
| 17 | import {Tooltip} from 'isl-components/Tooltip'; |
| 18 | import {useAtom} from 'jotai'; |
| 19 | import {basename} from 'shared/utils'; |
| 20 | import {spacing} from '../../components/theme/tokens.stylex'; |
| 21 | import serverAPI from './ClientToServerAPI'; |
| 22 | import {Collapsable} from './Collapsable'; |
| 23 | import {Internal} from './Internal'; |
| 24 | import {tracker} from './analytics'; |
| 25 | import {getFeatureFlag} from './featureFlags'; |
| 26 | import {T, t} from './i18n'; |
| 27 | import {localStorageBackedAtom, readAtom} from './jotaiUtils'; |
| 28 | import platform from './platform'; |
| 29 | import {uncommittedChangesWithPreviews} from './previews'; |
| 30 | import {showModal} from './useModal'; |
| 31 | |
| 32 | export const shouldWarnAboutDiagnosticsAtom = localStorageBackedAtom<boolean>( |
| 33 | 'isl.warn-about-diagnostics', |
| 34 | true, |
| 35 | ); |
| 36 | |
| 37 | const hideNonBlockingDiagnosticsAtom = localStorageBackedAtom<boolean>( |
| 38 | 'isl.hide-non-blocking-diagnostics', |
| 39 | true, |
| 40 | ); |
| 41 | |
| 42 | const styles = stylex.create({ |
| 43 | diagnosticList: { |
| 44 | paddingInline: spacing.double, |
| 45 | paddingBlock: spacing.half, |
| 46 | gap: 0, |
| 47 | }, |
| 48 | nowrap: { |
| 49 | whiteSpace: 'nowrap', |
| 50 | }, |
| 51 | diagnosticRow: { |
| 52 | maxWidth: 'max(400px, 80vw)', |
| 53 | padding: spacing.half, |
| 54 | cursor: 'pointer', |
| 55 | ':hover': { |
| 56 | backgroundColor: 'var(--hover-darken)', |
| 57 | }, |
| 58 | }, |
| 59 | allDiagnostics: { |
| 60 | maxHeight: 'calc(100vh - 200px)', |
| 61 | minHeight: '50px', |
| 62 | overflowY: 'scroll', |
| 63 | }, |
| 64 | confirmCheckbox: { |
| 65 | paddingTop: spacing.double, |
| 66 | }, |
| 67 | }); |
| 68 | |
| 69 | export function isBlockingDiagnostic( |
| 70 | d: Diagnostic, |
| 71 | /** Many diagnostics are low-quality and don't reflect what would appear on CI. |
| 72 | * Start with an allowlist while we validate which signals are worthwhile. */ |
| 73 | allowlistedCodesBySource: undefined | DiagnosticAllowlist = Internal.allowlistedDiagnosticCodes ?? |
| 74 | undefined, |
| 75 | ): boolean { |
| 76 | if (allowlistedCodesBySource == null) { |
| 77 | // In OSS, let's assume all errors are blocking. |
| 78 | return true; |
| 79 | } |
| 80 | if (d.severity !== 'error' && d.severity !== 'warning') { |
| 81 | return false; |
| 82 | } |
| 83 | if (allowlistedCodesBySource == null) { |
| 84 | return true; |
| 85 | } |
| 86 | // source/code may be missing, but we still want to route that through the allowlist |
| 87 | const source = d.source ?? 'undefined'; |
| 88 | const code = d.code ?? 'undefined'; |
| 89 | const relevantAllowlist = allowlistedCodesBySource.get(d.severity)?.get(source); |
| 90 | return ( |
| 91 | relevantAllowlist != null && |
| 92 | (relevantAllowlist.allow |
| 93 | ? relevantAllowlist.allow.has(code) === true |
| 94 | : relevantAllowlist.block.has(code) === false) |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | function isErrorDiagnosticToLog(d: Diagnostic): boolean { |
| 99 | return d.severity === 'error'; |
| 100 | } |
| 101 | |
| 102 | /** Render diagnostic to a string, in the format `Source(Code): Snippet of error message` */ |
| 103 | function previewDiagnostic(diagnostic: Diagnostic | undefined) { |
| 104 | return diagnostic != null |
| 105 | ? `${diagnostic.source}(${diagnostic.code}): ${diagnostic?.message.slice(0, 100)}` |
| 106 | : undefined; |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Check IDE diagnostics for files that will be commit/amended/submitted, |
| 111 | * to confirm if they intended the errors. |
| 112 | */ |
| 113 | export async function confirmNoBlockingDiagnostics( |
| 114 | /** Check diagnostics for these selected files. */ |
| 115 | selection: UseUncommittedSelection, |
| 116 | /** If provided, warn for changes to files in this commit. Used when checking diagnostics when amending a commit. */ |
| 117 | commit?: CommitInfo, |
| 118 | ): Promise<boolean> { |
| 119 | if (!readAtom(shouldWarnAboutDiagnosticsAtom)) { |
| 120 | return true; |
| 121 | } |
| 122 | if (platform.platformName === 'vscode') { |
| 123 | const allFiles = new Set<string>(); |
| 124 | for (const file of readAtom(uncommittedChangesWithPreviews)) { |
| 125 | if (selection.isFullyOrPartiallySelected(file.path)) { |
| 126 | allFiles.add(file.path); |
| 127 | } |
| 128 | } |
| 129 | for (const filePath of commit?.filePathsSample ?? []) { |
| 130 | allFiles.add(filePath); |
| 131 | } |
| 132 | |
| 133 | serverAPI.postMessage({ |
| 134 | type: 'platform/checkForDiagnostics', |
| 135 | paths: [...allFiles], |
| 136 | }); |
| 137 | const [result, enabled] = await Promise.all([ |
| 138 | serverAPI.nextMessageMatching('platform/gotDiagnostics', () => true), |
| 139 | getFeatureFlag( |
| 140 | Internal.featureFlags?.ShowPresubmitDiagnosticsWarning, |
| 141 | /* enable this feature in OSS */ true, |
| 142 | ), |
| 143 | ]); |
| 144 | if (result.diagnostics.size > 0) { |
| 145 | const allDiagnostics = [...result.diagnostics.values()]; |
| 146 | const allBlockingErrors = allDiagnostics |
| 147 | .map(value => value.filter(d => isBlockingDiagnostic(d))) |
| 148 | .flat(); |
| 149 | const totalErrors = allBlockingErrors.length; |
| 150 | |
| 151 | // It's useful to track even the diagnostics that are filtered out, to refine the allowlist in the future |
| 152 | const unfilteredErrors = allDiagnostics |
| 153 | .map(value => value.filter(isErrorDiagnosticToLog)) |
| 154 | .flat(); |
| 155 | |
| 156 | const totalDiagnostics = allDiagnostics.flat().length; |
| 157 | |
| 158 | const firstError = allBlockingErrors[0]; |
| 159 | const firstUnfilteredError = unfilteredErrors[0]; |
| 160 | |
| 161 | const childTracker = tracker.trackAsParent('DiagnosticsConfirmationOpportunity', { |
| 162 | extras: { |
| 163 | shown: enabled, |
| 164 | unfilteredErrorCodes: [...new Set(unfilteredErrors.map(d => `${d.source}(${d.code})`))], |
| 165 | filteredErrorCodes: [...new Set(allBlockingErrors.map(d => `${d.source}(${d.code})`))], |
| 166 | sampleMessage: previewDiagnostic(firstError), |
| 167 | unfilteredSampleMessage: previewDiagnostic(firstUnfilteredError), |
| 168 | totalUnfilteredErrors: unfilteredErrors.length, |
| 169 | totalErrors, |
| 170 | totalDiagnostics, |
| 171 | }, |
| 172 | }); |
| 173 | |
| 174 | if (!enabled) { |
| 175 | return true; |
| 176 | } |
| 177 | |
| 178 | if (totalErrors > 0) { |
| 179 | const buttons = [{label: 'Cancel'}, {label: 'Continue', primary: true}] as const; |
| 180 | const shouldContinue = |
| 181 | (await showModal({ |
| 182 | type: 'confirm', |
| 183 | title: ( |
| 184 | <Row> |
| 185 | <T count={totalErrors}>codeIssuesFound</T> |
| 186 | <Tooltip |
| 187 | title={t( |
| 188 | 'Error-severity issues are typically land blocking and should be resolved before submitting for code review.\n\n' + |
| 189 | 'Errors shown here are best-effort and not necessarily comprehensive.', |
| 190 | )}> |
| 191 | <Icon icon="info" /> |
| 192 | </Tooltip> |
| 193 | </Row> |
| 194 | ), |
| 195 | message: ( |
| 196 | <DiagnosticsList |
| 197 | diagnostics={[...result.diagnostics.entries()]} |
| 198 | tracker={childTracker} |
| 199 | /> |
| 200 | ), |
| 201 | buttons, |
| 202 | })) === buttons[1]; |
| 203 | |
| 204 | childTracker.track('DiagnosticsConfirmationAction', { |
| 205 | extras: { |
| 206 | action: shouldContinue ? 'continue' : 'cancel', |
| 207 | }, |
| 208 | }); |
| 209 | |
| 210 | return shouldContinue; |
| 211 | } |
| 212 | } |
| 213 | } |
| 214 | return true; |
| 215 | } |
| 216 | |
| 217 | function DiagnosticsList({ |
| 218 | diagnostics, |
| 219 | tracker, |
| 220 | }: { |
| 221 | diagnostics: Array<[string, Array<Diagnostic>]>; |
| 222 | tracker: Tracker<{parentId: string}>; |
| 223 | }) { |
| 224 | const [hideNonBlocking, setHideNonBlocking] = useAtom(hideNonBlockingDiagnosticsAtom); |
| 225 | const [shouldWarn, setShouldWarn] = useAtom(shouldWarnAboutDiagnosticsAtom); |
| 226 | return ( |
| 227 | <> |
| 228 | <Column alignStart xstyle={styles.allDiagnostics}> |
| 229 | {diagnostics.map(([filepath, diagnostics]) => { |
| 230 | const sortedDiagnostics = [...diagnostics].filter(d => |
| 231 | hideNonBlocking ? isBlockingDiagnostic(d) : true, |
| 232 | ); |
| 233 | sortedDiagnostics.sort((a, b) => { |
| 234 | return severityComparator(a) - severityComparator(b); |
| 235 | }); |
| 236 | if (sortedDiagnostics.length === 0) { |
| 237 | return null; |
| 238 | } |
| 239 | return ( |
| 240 | <Column key={filepath} alignStart> |
| 241 | <Collapsable |
| 242 | startExpanded |
| 243 | title={ |
| 244 | <Row> |
| 245 | <span>{basename(filepath)}</span> |
| 246 | <Subtle>{filepath}</Subtle> |
| 247 | </Row> |
| 248 | }> |
| 249 | <Column alignStart xstyle={styles.diagnosticList}> |
| 250 | {sortedDiagnostics.map((d, i) => ( |
| 251 | <Row |
| 252 | role="button" |
| 253 | tabIndex={0} |
| 254 | key={i} |
| 255 | xstyle={styles.diagnosticRow} |
| 256 | onClick={() => { |
| 257 | platform.openFile(filepath, {line: d.range.startLine + 1}); |
| 258 | tracker.track('DiagnosticsConfirmationAction', { |
| 259 | extras: {action: 'openFile'}, |
| 260 | }); |
| 261 | }}> |
| 262 | {iconForDiagnostic(d)} |
| 263 | <span>{d.message}</span> |
| 264 | {d.source && ( |
| 265 | <Subtle {...stylex.props(styles.nowrap)}> |
| 266 | {d.source} |
| 267 | {d.code ? `(${d.code})` : null} |
| 268 | </Subtle> |
| 269 | )}{' '} |
| 270 | <Subtle {...stylex.props(styles.nowrap)}> |
| 271 | [Ln {d.range.startLine}, Col {d.range.startCol}] |
| 272 | </Subtle> |
| 273 | </Row> |
| 274 | ))} |
| 275 | </Column> |
| 276 | </Collapsable> |
| 277 | </Column> |
| 278 | ); |
| 279 | })} |
| 280 | </Column> |
| 281 | <Row xstyle={styles.confirmCheckbox}> |
| 282 | <Checkbox |
| 283 | checked={!shouldWarn} |
| 284 | onChange={checked => { |
| 285 | setShouldWarn(!checked); |
| 286 | if (checked) { |
| 287 | tracker.track('DiagnosticsConfirmationAction', {extras: {action: 'dontAskAgain'}}); |
| 288 | } |
| 289 | }}> |
| 290 | <T>Don't ask again</T> |
| 291 | </Checkbox> |
| 292 | <Tooltip |
| 293 | title={t( |
| 294 | "Only 'error' severity issues known to cause problems will cause this dialog to appear, but less severe issues can still be shown here. This option hides these non-blocking issues.", |
| 295 | )}> |
| 296 | <Checkbox checked={hideNonBlocking} onChange={setHideNonBlocking}> |
| 297 | <T>Hide non-blocking issues</T> |
| 298 | </Checkbox> |
| 299 | </Tooltip> |
| 300 | </Row> |
| 301 | </> |
| 302 | ); |
| 303 | } |
| 304 | |
| 305 | function severityComparator(a: Diagnostic) { |
| 306 | switch (a.severity) { |
| 307 | case 'error': |
| 308 | return 0; |
| 309 | case 'warning': |
| 310 | return 1; |
| 311 | case 'info': |
| 312 | return 2; |
| 313 | case 'hint': |
| 314 | return 3; |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | function iconForDiagnostic(d: Diagnostic): React.ReactNode { |
| 319 | switch (d.severity) { |
| 320 | case 'error': |
| 321 | return <Icon icon="error" color="red" />; |
| 322 | case 'warning': |
| 323 | return <Icon icon="warning" color="yellow" />; |
| 324 | case 'info': |
| 325 | return <Icon icon="info" />; |
| 326 | case 'hint': |
| 327 | return <Icon icon="info" />; |
| 328 | } |
| 329 | } |
| 330 | |