diff --git a/.oxfmtrc.json b/.oxfmtrc.json index dd13cf977e3..3735b74f87d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -36,6 +36,7 @@ ".storybook/", "app/i18n/locales/", "app/containers/CustomIcon/mappedIcons.js", - "app/containers/CustomIcon/selection.json" + "app/containers/CustomIcon/selection.json", + "tools/oxlint/anti-slop/" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 31014ddd314..76cc51f1689 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,7 +1,13 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": ["import", "react", "jest"], - "jsPlugins": ["eslint-plugin-react-native"], + "jsPlugins": [ + "eslint-plugin-react-native", + { + "name": "anti-slop", + "specifier": "./tools/oxlint/anti-slop/index.ts" + } + ], "categories": { "correctness": "off" }, @@ -17,7 +23,20 @@ "**/android", "**/ios", "**/.worktrees/", - ".rnstorybook/storybook.requires.ts" + ".rnstorybook/storybook.requires.ts", + ".agent/**", + ".agents/**", + ".claude/**", + ".codex/**", + ".continue/**", + ".cursor/**", + ".gemini/**", + ".opencode/**", + ".pi/**", + ".roo/**", + ".windsurf/**", + ".sniffler/**", + "tools/oxlint/anti-slop/**" ], "rules": { "import/extensions": "off", @@ -42,7 +61,18 @@ "require-await": "error", "react/exhaustive-deps": "warn", "react/rules-of-hooks": "error", - "react/react-compiler": "warn" + "anti-slop/no-chained-type-assertions": "warn", + "anti-slop/no-conditional-empty-object-spread": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-module-mocking": "warn", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-unsafe-dictionary-type": "warn", + "anti-slop/no-widen-then-assert": "error", + "anti-slop/require-safety-comment-for-type-assertion": "warn" }, "overrides": [ { diff --git a/app/actions/actionsTypes.ts b/app/actions/actionsTypes.ts index 06445d9600e..78ea104eaf8 100644 --- a/app/actions/actionsTypes.ts +++ b/app/actions/actionsTypes.ts @@ -5,6 +5,7 @@ const defaultTypes = [REQUEST, SUCCESS, FAILURE]; function createRequestTypes(base = {}, types = defaultTypes): Record { const res: Record = {}; types.forEach(type => (res[type] = `${base}_${type}`)); + // oxlint-disable-next-line anti-slop/no-known-value-widening -- the action-type keys come from a runtime array, not a literal return res; } diff --git a/app/containers/List/ListItem.tsx b/app/containers/List/ListItem.tsx index 62e3f273299..0c5ade36e2c 100644 --- a/app/containers/List/ListItem.tsx +++ b/app/containers/List/ListItem.tsx @@ -63,9 +63,7 @@ const styles = StyleSheet.create({ fontSize: 14, ...sharedStyles.textRegular }, - actionIndicator: { - ...(I18nManager.isRTL ? { transform: [{ rotate: '180deg' }] } : {}) - } + actionIndicator: I18nManager.isRTL ? { transform: [{ rotate: '180deg' }] } : {} }); interface IListTitle extends Pick {} diff --git a/app/containers/LoginServices/serviceLogin.ts b/app/containers/LoginServices/serviceLogin.ts index 8eafda139bc..54c028aa111 100644 --- a/app/containers/LoginServices/serviceLogin.ts +++ b/app/containers/LoginServices/serviceLogin.ts @@ -161,19 +161,8 @@ const openOAuthSession = async (url: string) => { const getOAuthState = (loginStyle: TLoginStyle = 'popup') => { const credentialToken = random(43); - let obj: { - loginStyle: string; - credentialToken: string; - isCordova: boolean; - redirectUrl?: string; - } = { loginStyle, credentialToken, isCordova: true }; - if (loginStyle === 'redirect') { - obj = { - ...obj, - redirectUrl: 'rocketchat://auth' - }; - } - return Base64.encodeURI(JSON.stringify(obj)); + const state = { loginStyle, credentialToken, isCordova: true }; + return Base64.encodeURI(JSON.stringify(loginStyle === 'redirect' ? { ...state, redirectUrl: 'rocketchat://auth' } : state)); }; const openSSOWebView = ({ url, ssoToken, authType }: IOpenSSOWebView) => { diff --git a/app/containers/MessageComposer/constants.ts b/app/containers/MessageComposer/constants.ts index a40766b7d60..dfd9090379e 100644 --- a/app/containers/MessageComposer/constants.ts +++ b/app/containers/MessageComposer/constants.ts @@ -26,12 +26,12 @@ export const MAX_HEIGHT = 200; export const NO_CANNED_RESPONSES = 'no-canned-responses'; -export const MARKDOWN_STYLES: Record = { +export const MARKDOWN_STYLES = { bold: '*', italic: '_', strike: '~', code: '`', 'code-block': '```' -}; +} satisfies Record; export const COMPOSER_INPUT_PLACEHOLDER_MAX_LENGTH = 30; diff --git a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx index bf538ddaf89..ba87b628fad 100644 --- a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx +++ b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx @@ -40,7 +40,11 @@ assertType(); // Jest hoists jest.mock() to the top of the module; factories can only close // over variables whose names start with "mock". Wrapping in an object lets us // reassign across the beforeEach without recreating the module mock. -const mockSdkState: { streamHandler: ((msg: IDDPMessage) => void) | null } = { streamHandler: null }; +interface IMockSdkState { + streamHandler: ((msg: IDDPMessage) => void) | null; +} + +const mockSdkState: IMockSdkState = { streamHandler: null }; jest.mock('../../lib/database', () => ({ db: { get: jest.fn() }, diff --git a/app/containers/ThemeContextProvider.test.tsx b/app/containers/ThemeContextProvider.test.tsx index 0ce40730b3f..6eb8eb08176 100644 --- a/app/containers/ThemeContextProvider.test.tsx +++ b/app/containers/ThemeContextProvider.test.tsx @@ -3,13 +3,13 @@ import { useContext, useState } from 'react'; import { TouchableOpacity } from 'react-native'; import ThemeContextProvider from './ThemeContextProvider'; -import { ThemeContext } from '../theme'; +import { ThemeContext, type IThemeContextProps } from '../theme'; import type { IThemePreference } from '../definitions/ITheme'; const defaultPrefs: IThemePreference = { currentTheme: 'light', darkLevel: 'dark' }; const setTheme = jest.fn(); -function ContextCapture({ onCapture }: { onCapture: (v: object) => void }) { +function ContextCapture({ onCapture }: { onCapture: (v: IThemeContextProps) => void }) { const value = useContext(ThemeContext); onCapture(value); return null; @@ -18,7 +18,7 @@ function ContextCapture({ onCapture }: { onCapture: (v: object) => void }) { // Parent holds its own counter state; ThemeContextProvider receives fixed props. // This forces ThemeContextProvider to re-render on parent state changes, exercising the // useMemo dep-check rather than React's same-props bailout shortcut. -function ParentWithCounter({ onCapture }: { onCapture: (v: object) => void }) { +function ParentWithCounter({ onCapture }: { onCapture: (v: IThemeContextProps) => void }) { const [count, setCount] = useState(0); return ( <> diff --git a/app/containers/TwoFactor/index.tsx b/app/containers/TwoFactor/index.tsx index 0aa2acae2c2..07936fa6b9d 100644 --- a/app/containers/TwoFactor/index.tsx +++ b/app/containers/TwoFactor/index.tsx @@ -63,7 +63,7 @@ const methods: IMethods = { }; const TwoFactor = memo(() => { - const schema = yup.object().shape({ + const schema = yup.object({ code: yup.string().required(I18n.t('Code_required')) }); const { colors } = useTheme(); diff --git a/app/containers/UIKit/Icon.tsx b/app/containers/UIKit/Icon.tsx index 9b6fd1a6035..eee5587a68b 100644 --- a/app/containers/UIKit/Icon.tsx +++ b/app/containers/UIKit/Icon.tsx @@ -3,14 +3,19 @@ import { StyleSheet, View } from 'react-native'; import { hasIcon, CustomIcon } from '../CustomIcon'; import { useTheme } from '../../theme'; import { type IIcon } from './interfaces'; +import { hasOwnKey } from '../../lib/methods/helpers/hasOwnKey'; -const iconAliases: Record = { +const iconAliases = { 'phone-end': 'phone-off', microphone: 'mic', 'microphone-disabled': 'mic-off', audio: 'volume', 'audio-disabled': 'volume-off' -}; +} satisfies Record; + +type TIconAlias = keyof typeof iconAliases; + +const isIconAlias = (icon: string): icon is TIconAlias => hasOwnKey(iconAliases, icon); const styles = StyleSheet.create({ frame: { @@ -27,9 +32,11 @@ export const resolveIconName = (icon: string) => { return icon as any; } - const aliasedIcon = iconAliases[icon]; - if (aliasedIcon && hasIcon(aliasedIcon)) { - return aliasedIcon as any; + if (isIconAlias(icon)) { + const aliasedIcon = iconAliases[icon]; + if (hasIcon(aliasedIcon)) { + return aliasedIcon as any; + } } return 'info' as any; diff --git a/app/containers/UIKit/Select.tsx b/app/containers/UIKit/Select.tsx index 7759bc796eb..b5103ba17bd 100644 --- a/app/containers/UIKit/Select.tsx +++ b/app/containers/UIKit/Select.tsx @@ -48,12 +48,12 @@ export const Select = ({ options = [], placeholder, onChange, loading, disabled, const { theme } = useTheme(); const [selected, setSelected] = useState(!Array.isArray(initialValue) && initialValue); const items = options.map(option => ({ label: textParser([option.text]), value: option.value })); - const pickerStyle = { + const basePickerStyle = { ...styles.viewContainer, - ...(isIOS ? styles.iosPadding : {}), borderColor: themes[theme].strokeLight, backgroundColor: themes[theme].surfaceRoom }; + const pickerStyle = isIOS ? { ...basePickerStyle, ...styles.iosPadding } : basePickerStyle; const placeholderObject = useMemo( () => diff --git a/app/containers/UIKit/UiKitMessage.stories.tsx b/app/containers/UIKit/UiKitMessage.stories.tsx index ef82eeafd75..2385e2dbfc3 100644 --- a/app/containers/UIKit/UiKitMessage.stories.tsx +++ b/app/containers/UIKit/UiKitMessage.stories.tsx @@ -558,8 +558,8 @@ const getInfoCardAction = ({ }) => ({ type: 'icon_button', actionId: 'open-history', - ...(appId ? { appId } : {}), - ...(blockId ? { blockId } : {}), + ...(appId && { appId }), + ...(blockId && { blockId }), label: label ?? 'Call history', icon: { type: 'icon', diff --git a/app/containers/UIKit/VideoConferenceBlock/components/VideoConferenceBaseContainer.tsx b/app/containers/UIKit/VideoConferenceBlock/components/VideoConferenceBaseContainer.tsx index 2024aa9d941..c4cef571c48 100644 --- a/app/containers/UIKit/VideoConferenceBlock/components/VideoConferenceBaseContainer.tsx +++ b/app/containers/UIKit/VideoConferenceBlock/components/VideoConferenceBaseContainer.tsx @@ -15,7 +15,7 @@ export const VideoConferenceBaseContainer = ({ variant, children }: VideoConfMes const { colors } = useTheme(); const style = useStyle(); - const iconStyle: { [key: string]: { icon: TIconsName; color: string; backgroundColor: string; label: string } } = { + const iconStyle = { ended: { icon: 'phone-off', color: colors.fontSecondaryInfo, @@ -40,7 +40,10 @@ export const VideoConferenceBaseContainer = ({ variant, children }: VideoConfMes backgroundColor: colors.statusBackgroundWarning, label: i18n.t('Call_issue') } - }; + } satisfies Record< + VideoConfMessageIconProps['variant'], + { icon: TIconsName; color: string; backgroundColor: string; label: string } + >; return ( diff --git a/app/containers/message/hooks/useMessageAccessibilityLabel.ts b/app/containers/message/hooks/useMessageAccessibilityLabel.ts index aa1f3c0ee7c..cbd09c3d2b9 100644 --- a/app/containers/message/hooks/useMessageAccessibilityLabel.ts +++ b/app/containers/message/hooks/useMessageAccessibilityLabel.ts @@ -1,5 +1,5 @@ import i18n from '../../../i18n'; -import translationLanguages from '../../../lib/constants/translationLanguages'; +import translationLanguages, { isTranslationLanguage } from '../../../lib/constants/translationLanguages'; import { useImageDescriptionLabel } from './useImageDescriptionLabel'; import { getInfoMessage } from '../utils'; import { type IUserChannel, type IUserMention } from '../../../definitions'; @@ -67,7 +67,8 @@ export const useMessageAccessibilityLabel = (): string => { const readOrUnreadLabel = !unread && unread !== null ? i18n.t('Message_was_read') : i18n.t('Message_was_not_read'); const readReceipt = isReadReceiptEnabled && !isInfo ? readOrUnreadLabel : ''; const encryptedMessageLabel = isEncrypted ? i18n.t('Encrypted_message') : ''; - const translatedLanguage = translationLanguages[autoTranslateLanguage || 'en']; + const language = autoTranslateLanguage || 'en'; + const translatedLanguage = isTranslationLanguage(language) ? translationLanguages[language] : language; const translated = isTranslated ? i18n.t('Message_translated_into_idiom', { idiom: translatedLanguage }) : ''; // For translated messages, the translated body is announced by the inner A11y.Index node, so the outer label // only carries the metadata (user, hour, translated marker) and the suffix (image description, encryption, read receipt). diff --git a/app/containers/message/stores/MessageRoomStore.tsx b/app/containers/message/stores/MessageRoomStore.tsx index 8f6914c6c07..c094c683e26 100644 --- a/app/containers/message/stores/MessageRoomStore.tsx +++ b/app/containers/message/stores/MessageRoomStore.tsx @@ -14,7 +14,7 @@ export type MessageRoomState = { fetchThreadName?: (tmid: string, id: string) => Promise; toggleFollowThread?: (isFollowingThread: boolean, tmid?: string) => Promise; jumpToMessage?: (link: string) => void; - closeEmojiAndAction?: (action?: (params?: unknown) => void, params?: unknown) => void; + closeEmojiAndAction?: (action?: () => void) => void; // row action handlers onReactionPress?: (emoji: string, id: string) => void; onReactionLongPress?: (item: TAnyMessageModel) => void; diff --git a/app/containers/message/stores/MessageStore.tsx b/app/containers/message/stores/MessageStore.tsx index 09d6f0b81ea..c8e87284ae4 100644 --- a/app/containers/message/stores/MessageStore.tsx +++ b/app/containers/message/stores/MessageStore.tsx @@ -325,10 +325,16 @@ export const useMessageIgnored = (): boolean => useMessageStore(s => (s.manualUn export const useRevealIgnored = (): (() => void) => useMessageStore(s => s.reveal); +interface IUseMessageTouchableResult { + tappable: boolean; + longPressable: boolean; + revealsIgnored: boolean; +} + // Single source of truth for pressability, shared by the Touch gate, long-press guard and // press guard. longPressable drops encrypted messages (tap can still open a thread; the action // sheet is suppressed); revealsIgnored is tappable ∧ isIgnored (a tap reveals instead of pressing). -export const useMessageTouchable = (): { tappable: boolean; longPressable: boolean; revealsIgnored: boolean } => { +export const useMessageTouchable = (): IUseMessageTouchableResult => { const isInfo = useIsInfoMessage(); const { hasError, isTemp } = useMessageStatus(); const isEncrypted = useIsEncrypted(); diff --git a/app/definitions/TUserStatus.ts b/app/definitions/TUserStatus.ts index 4970c51eebc..9c4141b4869 100644 --- a/app/definitions/TUserStatus.ts +++ b/app/definitions/TUserStatus.ts @@ -2,9 +2,11 @@ export const STATUSES = ['offline', 'online', 'away', 'busy', 'disabled', 'loadi export type TUserStatus = (typeof STATUSES)[number]; -export const STATUS_I18N_KEYS: Partial> = { +export const STATUS_I18N_KEYS = { online: 'Online', away: 'Away', busy: 'Busy', - offline: 'Offline' -}; + offline: 'Offline', + disabled: undefined, + loading: undefined +} satisfies Record; diff --git a/app/ee/omnichannel/containers/OmnichannelHeader/styles.ts b/app/ee/omnichannel/containers/OmnichannelHeader/styles.ts index d95f4301124..39b16d2d518 100644 --- a/app/ee/omnichannel/containers/OmnichannelHeader/styles.ts +++ b/app/ee/omnichannel/containers/OmnichannelHeader/styles.ts @@ -17,7 +17,5 @@ export default StyleSheet.create({ ...sharedStyles.textRegular, fontSize: 12 }, - actionIndicator: { - ...(I18nManager.isRTL ? { transform: [{ rotate: '180deg' }] } : {}) - } + actionIndicator: I18nManager.isRTL ? { transform: [{ rotate: '180deg' }] } : {} }); diff --git a/app/i18n/dayjs.ts b/app/i18n/dayjs.ts index 32879525694..aee8508e6d2 100644 --- a/app/i18n/dayjs.ts +++ b/app/i18n/dayjs.ts @@ -1,4 +1,6 @@ -const localeKeys: { [key: string]: string } = { +import { hasOwnKey } from '../lib/methods/helpers/hasOwnKey'; + +const localeKeys = { en: 'en', ar: 'ar', de: 'de', @@ -17,6 +19,10 @@ const localeKeys: { [key: string]: string } = { 'zh-CN': 'zh-cn', 'zh-TW': 'zh-tw', no: 'nb' -}; +} satisfies Record; + +type TLocaleKey = keyof typeof localeKeys; + +const isLocaleKey = (locale: string): locale is TLocaleKey => hasOwnKey(localeKeys, locale); -export const toDayJsLocale = (locale: string): string => localeKeys[locale] || locale; +export const toDayJsLocale = (locale: string): string => (isLocaleKey(locale) ? localeKeys[locale] : locale); diff --git a/app/lib/constants/keys.ts b/app/lib/constants/keys.ts index 8c6dc1da7d2..ef5c2ad261d 100644 --- a/app/lib/constants/keys.ts +++ b/app/lib/constants/keys.ts @@ -1,3 +1,5 @@ +import { hasOwnKey } from '../methods/helpers/hasOwnKey'; + export const E2E_MESSAGE_TYPE = 'e2e'; export const E2E_PUBLIC_KEY = 'RC_E2E_PUBLIC_KEY'; export const E2E_PRIVATE_KEY = 'RC_E2E_PRIVATE_KEY'; @@ -10,10 +12,13 @@ export const E2E_BANNER_TYPE = { REQUEST_PASSWORD: 'REQUEST_PASSWORD', SAVE_PASSWORD: 'SAVE_PASSWORD' }; -export const E2E_ROOM_TYPES: Record = { +export const E2E_ROOM_TYPES = { d: 'd', p: 'p' -}; +} satisfies Record; + +export const isE2ERoomType = (roomType?: string): roomType is keyof typeof E2E_ROOM_TYPES => + roomType !== undefined && hasOwnKey(E2E_ROOM_TYPES, roomType); export const THEME_PREFERENCES_KEY = 'RC_THEME_PREFERENCES_KEY'; export const USER_MENTIONS_PREFERENCES_KEY = 'RC_USER_MENTIONS_PREFERENCES_KEY'; diff --git a/app/lib/constants/translationLanguages.ts b/app/lib/constants/translationLanguages.ts index e75af7f1f73..aab364aa839 100644 --- a/app/lib/constants/translationLanguages.ts +++ b/app/lib/constants/translationLanguages.ts @@ -1,4 +1,6 @@ -const translationLanguages: Record = { +import { hasOwnKey } from '../methods/helpers/hasOwnKey'; + +const translationLanguages = { af: 'Afrikaans', ar: 'Arabic', az: 'Azerbaijani', @@ -200,6 +202,9 @@ const translationLanguages: Record = { yi: 'Yiddish', yo: 'Yoruba', yua: 'Yucatec Maya' -}; +} satisfies Record; + +export const isTranslationLanguage = (language: string): language is keyof typeof translationLanguages => + hasOwnKey(translationLanguages, language); export default translationLanguages; diff --git a/app/lib/database/utils.ts b/app/lib/database/utils.ts index 547582fc871..03b22e8d020 100644 --- a/app/lib/database/utils.ts +++ b/app/lib/database/utils.ts @@ -34,4 +34,4 @@ export const getSubscriptionSearchClause = (searchText: string): Q.Or => { ); }; -export const sanitizer = (r: object): object => r; +export const sanitizer = (r: T): T => r; diff --git a/app/lib/encryption/helpers/deferred.ts b/app/lib/encryption/helpers/deferred.ts index 2a4807e77c1..ce3e02429cf 100644 --- a/app/lib/encryption/helpers/deferred.ts +++ b/app/lib/encryption/helpers/deferred.ts @@ -1,35 +1,35 @@ -export default class Deferred { - private promise: Promise; - private _resolve: (value?: unknown) => void; +export default class Deferred { + private promise: Promise; + private _resolve: (value: T) => void; private _reject: (reason?: any) => void; constructor() { this._resolve = () => {}; this._reject = () => {}; this.promise = new Promise((resolve, reject) => { - this._resolve = resolve as (value?: unknown) => void; + this._resolve = resolve; this._reject = reject; }); } - public then( - onfulfilled?: ((value: unknown) => TResult1 | PromiseLike) | undefined | null, + public then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null ): Promise { return this.promise.then(onfulfilled, onrejected); } - public catch( + public catch( onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null - ): Promise { + ): Promise { return this.promise.catch(onrejected); } - public finally(onfinally?: (() => void) | null | undefined): Promise { + public finally(onfinally?: (() => void) | null | undefined): Promise { return this.promise.finally(onfinally); } - public resolve(value?: unknown): void { + public resolve(value: T): void { this._resolve(value); } diff --git a/app/lib/encryption/room.ts b/app/lib/encryption/room.ts index a837847923b..a612360039e 100644 --- a/app/lib/encryption/room.ts +++ b/app/lib/encryption/room.ts @@ -67,6 +67,12 @@ import { store } from '../store/auxStore'; type TAlgorithm = 'A128CBC' | 'A256GCM' | ''; +interface IParsedContent { + kid: string; + iv: ArrayBuffer; + ciphertext: string; +} + export default class EncryptionRoom { ready: boolean; roomId: string; @@ -616,13 +622,7 @@ export default class EncryptionRoom { return data; }; - parse = ( - payload: string | IMessage['content'] - ): { - kid: string; - iv: ArrayBuffer; - ciphertext: string; - } => { + parse = (payload: string | IMessage['content']): IParsedContent => { // v2: {"kid":"...", "iv": "...", "ciphertext":"..."} if (typeof payload !== 'string' && payload?.algorithm === 'rc.v2.aes-sha2') { return { kid: payload.kid, iv: b64ToBuffer(payload.iv), ciphertext: payload.ciphertext }; diff --git a/app/lib/encryption/utils.ts b/app/lib/encryption/utils.ts index 3905816ac2a..8648f767f65 100644 --- a/app/lib/encryption/utils.ts +++ b/app/lib/encryption/utils.ts @@ -236,10 +236,15 @@ export const encodePrefixedBase64 = (prefix: string, data: ArrayBuffer): string return prefix + base64Data; }; -export const parsePrivateKey = ( - privateKey: string, - userId: string -): { iv: ArrayBuffer; ciphertext: ArrayBuffer; salt: string; iterations: number; version: 'v1' | 'v2' } => { +interface IParsedPrivateKey { + iv: ArrayBuffer; + ciphertext: ArrayBuffer; + salt: string; + iterations: number; + version: 'v1' | 'v2'; +} + +export const parsePrivateKey = (privateKey: string, userId: string): IParsedPrivateKey => { const json: unknown = JSON.parse(privateKey); if (typeof json !== 'object' || json === null) { throw new TypeError('Invalid private key format'); diff --git a/app/lib/hooks/useEndpointData.ts b/app/lib/hooks/useEndpointData.ts index cfbd6ac737a..122f5960d25 100644 --- a/app/lib/hooks/useEndpointData.ts +++ b/app/lib/hooks/useEndpointData.ts @@ -11,6 +11,13 @@ import { } from '../../definitions/rest/helpers'; import sdk from '../services/sdk'; +export interface IUseEndpointDataResult> { + result: Serialized>> | undefined; + loading: boolean; + reload: () => void; + error: ErrorResult | undefined; +} + export const useEndpointData = >( endpoint: TPath, params: void extends OperationParams<'GET', MatchPathPattern> @@ -21,12 +28,7 @@ export const useEndpointData = >( > ? void : Serialized>> -): { - result: Serialized>> | undefined; - loading: boolean; - reload: Function; - error: ErrorResult | undefined; -} => { +): IUseEndpointDataResult => { const [loading, setLoading] = useState(true); const [result, setResult] = useState>> | undefined>(); const [error, setError] = useState(); diff --git a/app/lib/hooks/useFrequentlyUsedEmoji.ts b/app/lib/hooks/useFrequentlyUsedEmoji.ts index a5a83cd59e9..d8816237084 100644 --- a/app/lib/hooks/useFrequentlyUsedEmoji.ts +++ b/app/lib/hooks/useFrequentlyUsedEmoji.ts @@ -3,12 +3,12 @@ import { useEffect, useState } from 'react'; import { type IEmoji } from '../../definitions'; import { getFrequentlyUsedEmojis } from '../methods/emojis'; -export const useFrequentlyUsedEmoji = ( - withDefaultEmojis = false -): { +export interface IUseFrequentlyUsedEmojiResult { frequentlyUsed: IEmoji[]; loaded: boolean; -} => { +} + +export const useFrequentlyUsedEmoji = (withDefaultEmojis = false): IUseFrequentlyUsedEmojiResult => { const [frequentlyUsed, setFrequentlyUsed] = useState([]); const [loaded, setLoaded] = useState(false); useEffect(() => { diff --git a/app/lib/hooks/useShortnameToUnicode/ascii.ts b/app/lib/hooks/useShortnameToUnicode/ascii.ts index 7eca5f7df94..f483d9d572a 100644 --- a/app/lib/hooks/useShortnameToUnicode/ascii.ts +++ b/app/lib/hooks/useShortnameToUnicode/ascii.ts @@ -3,7 +3,9 @@ /* eslint-disable object-curly-spacing */ /* eslint-disable comma-spacing */ /* eslint-disable key-spacing */ -const ascii: { [key: string]: string } = { +import { hasOwnKey } from '../../methods/helpers/hasOwnKey'; + +const ascii = { '*\\0/*': '🙆', '*\\O/*': '🙆', '-___-': '😑', @@ -119,7 +121,9 @@ const ascii: { [key: string]: string } = { '=)': '🙂', ':]': '🙂', ':D': '😄' -}; +} satisfies Record; + +export const isAsciiEmoji = (emoji: string): emoji is keyof typeof ascii => hasOwnKey(ascii, emoji); export const asciiRegexp = "(\\*\\\\0\\/\\*|\\*\\\\O\\/\\*|\\-___\\-|\\:'\\-\\)|'\\:\\-\\)|'\\:\\-D|\\>\\:\\-\\)|>\\:\\-\\)|'\\:\\-\\(|\\>\\:\\-\\(|>\\:\\-\\(|\\:'\\-\\(|O\\:\\-\\)|0\\:\\-3|0\\:\\-\\)|0;\\^\\)|O;\\-\\)|0;\\-\\)|O\\:\\-3|\\-__\\-|\\:\\-Þ|\\:\\-Þ|\\<\\/3|<\\/3|\\:'\\)|\\:\\-D|'\\:\\)|'\\=\\)|'\\:D|'\\=D|\\>\\:\\)|>\\:\\)|\\>;\\)|>;\\)|\\>\\=\\)|>\\=\\)|;\\-\\)|\\*\\-\\)|;\\-\\]|;\\^\\)|'\\:\\(|'\\=\\(|\\:\\-\\*|\\:\\^\\*|\\>\\:P|>\\:P|X\\-P|\\>\\:\\[|>\\:\\[|\\:\\-\\(|\\:\\-\\[|\\>\\:\\(|>\\:\\(|\\:'\\(|;\\-\\(|\\>\\.\\<|>\\.<|#\\-\\)|%\\-\\)|X\\-\\)|\\\\0\\/|\\\\O\\/|0\\:3|0\\:\\)|O\\:\\)|O\\=\\)|O\\:3|B\\-\\)|8\\-\\)|B\\-D|8\\-D|\\-_\\-|\\>\\:\\\\|>\\:\\\\|\\>\\:\\/|>\\:\\/|\\:\\-\\/|\\:\\-\\.|\\:\\-P|\\:Þ|\\:Þ|\\:\\-b|\\:\\-O|O_O|\\>\\:O|>\\:O|\\:\\-X|\\:\\-#|\\:\\-\\)|\\(y\\)|\\<3|<3|\\=D|;\\)|\\*\\)|;\\]|;D|\\:\\*|\\=\\*|\\:\\(|\\:\\[|\\=\\(|\\:@|;\\(|D\\:|\\:\\$|\\=\\$|#\\)|%\\)|X\\)|B\\)|8\\)|\\:\\/|\\:\\\\|\\=\\/|\\=\\\\|\\:L|\\=L|\\:P|\\=P|\\:b|\\:O|\\:X|\\:#|\\=X|\\=#|\\:\\)|\\=\\]|\\=\\)|\\:\\]|\\:D)"; diff --git a/app/lib/hooks/useShortnameToUnicode/emojis.ts b/app/lib/hooks/useShortnameToUnicode/emojis.ts index 4d899d04338..d3cf81a407f 100644 --- a/app/lib/hooks/useShortnameToUnicode/emojis.ts +++ b/app/lib/hooks/useShortnameToUnicode/emojis.ts @@ -3,7 +3,9 @@ /* eslint-disable object-curly-spacing */ /* eslint-disable comma-spacing */ /* eslint-disable key-spacing */ -const emojis: { [key: string]: string } = { +import { hasOwnKey } from '../../methods/helpers/hasOwnKey'; + +const emojis = { ':england:': '🏴󠁧󠁢󠁥󠁮󠁧󠁿', ':scotland:': '🏴󠁧󠁢󠁳󠁣󠁴󠁿', ':wales:': '🏴󠁧󠁢󠁷󠁬󠁳󠁿', @@ -4632,5 +4634,8 @@ const emojis: { [key: string]: string } = { ':x:': '❌', ':yin_yang:': '☯️', ':zap:': '⚡' -}; +} satisfies Record; + +export const isEmojiShortname = (shortname: string): shortname is keyof typeof emojis => hasOwnKey(emojis, shortname); + export default emojis; diff --git a/app/lib/hooks/useShortnameToUnicode/index.tsx b/app/lib/hooks/useShortnameToUnicode/index.tsx index c6118456c89..38dc1ed3849 100644 --- a/app/lib/hooks/useShortnameToUnicode/index.tsx +++ b/app/lib/hooks/useShortnameToUnicode/index.tsx @@ -1,50 +1,53 @@ -import emojis from './emojis'; -import ascii, { asciiRegexp } from './ascii'; +import emojis, { isEmojiShortname } from './emojis'; +import ascii, { asciiRegexp, isAsciiEmoji } from './ascii'; import { useAppSelector } from '../useAppSelector'; import { getUserSelector } from '../../../selectors/login'; +import { hasOwnKey } from '../../methods/helpers/hasOwnKey'; const shortnamePattern = new RegExp(/:[-+_a-z0-9]+:/, 'gi'); -const replaceShortNameWithUnicode = (shortname: string) => emojis[shortname] || shortname; +const replaceShortNameWithUnicode = (shortname: string) => (isEmojiShortname(shortname) ? emojis[shortname] : shortname); const regAscii = new RegExp(`((\\s|^)${asciiRegexp}(?=\\s|$|[!,.?]))`, 'gi'); -const unescapeHTML = (string: string) => { - const unescaped: { [key: string]: string } = { - '&': '&', - '&': '&', - '&': '&', - '<': '<', - '<': '<', - '<': '<', - '>': '>', - '>': '>', - '>': '>', - '"': '"', - '"': '"', - '"': '"', - ''': "'", - ''': "'", - ''': "'" - }; - - return string.replace(/&(?:amp|#38|#x26|lt|#60|#x3C|gt|#62|#x3E|apos|#39|#x27|quot|#34|#x22);/gi, match => unescaped[match]); -}; +const htmlEntities = { + '&': '&', + '&': '&', + '&': '&', + '<': '<', + '<': '<', + '<': '<', + '>': '>', + '>': '>', + '>': '>', + '"': '"', + '"': '"', + '"': '"', + ''': "'", + ''': "'", + ''': "'" +} satisfies Record; + +const isHtmlEntity = (entity: string): entity is keyof typeof htmlEntities => hasOwnKey(htmlEntities, entity); + +const unescapeHTML = (string: string) => + string.replace(/&(?:amp|#38|#x26|lt|#60|#x3C|gt|#62|#x3E|apos|#39|#x27|quot|#34|#x22);/gi, match => + isHtmlEntity(match) ? htmlEntities[match] : match + ); const useShortnameToUnicode = (isEmojiPicker?: boolean) => { const convertAsciiEmoji = useAppSelector(state => getUserSelector(state)?.settings?.preferences?.convertAsciiEmoji); const formatShortnameToUnicode = (str: string) => { str = str.replace(shortnamePattern, replaceShortNameWithUnicode); str = str.replace(regAscii, (entire, _m1, m2, m3) => { - if (!m3 || !(unescapeHTML(m3) in ascii)) { + const asciiEmoji = m3 ? unescapeHTML(m3) : m3; + if (!asciiEmoji || !isAsciiEmoji(asciiEmoji)) { // if the ascii doesnt exist just return the entire match return entire; } - m3 = unescapeHTML(m3); - if (!convertAsciiEmoji && !isEmojiPicker) { - return m2 + m3; + return m2 + asciiEmoji; } - return m2 + ascii[m3]; + return m2 + ascii[asciiEmoji]; }); return str; }; diff --git a/app/lib/hooks/useShortnameToUnicode/useShortnameToUnicode.test.ts b/app/lib/hooks/useShortnameToUnicode/useShortnameToUnicode.test.ts index a1492a2eaa4..a8ab335cc72 100644 --- a/app/lib/hooks/useShortnameToUnicode/useShortnameToUnicode.test.ts +++ b/app/lib/hooks/useShortnameToUnicode/useShortnameToUnicode.test.ts @@ -109,3 +109,11 @@ test('convert ascii when convertAsciiEmoji = true and isEmojiPicker = true', () const unicodeEmoji = renderShortnameToUnicode(':(', true); expect(unicodeEmoji).toBe('😞'); }); + +test('keeps inherited object keys as literal text', () => { + expect(renderShortnameToUnicode(':toString:')).toBe(':toString:'); + expect(renderShortnameToUnicode(':constructor:')).toBe(':constructor:'); + expect(renderShortnameToUnicode(':__proto__:')).toBe(':__proto__:'); + expect(renderShortnameToUnicode('&constructor;')).toBe('&constructor;'); + expect(renderShortnameToUnicode('&valueOf;')).toBe('&valueOf;'); +}); diff --git a/app/lib/hooks/useVerifyPassword.test.tsx b/app/lib/hooks/useVerifyPassword.test.tsx index 9b0842a946f..beb6cf36eca 100644 --- a/app/lib/hooks/useVerifyPassword.test.tsx +++ b/app/lib/hooks/useVerifyPassword.test.tsx @@ -16,6 +16,8 @@ type TPolicySettings = | 'Accounts_Password_Policy_Enabled' | 'Accounts_Password_Policy_AtLeastOneNumber'; +type TPolicySettingValues = { [K in TPolicySettings]?: boolean | number }; + const mockUseSetting = useSetting as jest.Mock; describe('useVerifyPassword', () => { @@ -50,7 +52,7 @@ describe('useVerifyPassword', () => { it('should validate password based on policies', () => { mockUseSetting.mockImplementation((key: TPolicySettings) => { - const settings: Partial> = { + const settings: TPolicySettingValues = { Accounts_Password_Policy_Enabled: true, Accounts_Password_Policy_AtLeastOneLowercase: true, Accounts_Password_Policy_AtLeastOneUppercase: true, @@ -77,7 +79,7 @@ describe('useVerifyPassword', () => { describe('validate password policies', () => { it('should return false if password does not meet the Accounts_Password_Policy_AtLeastOneLowercase policy', () => { mockUseSetting.mockImplementation((key: TPolicySettings) => { - const settings: Partial> = { + const settings: TPolicySettingValues = { Accounts_Password_Policy_Enabled: true, Accounts_Password_Policy_AtLeastOneLowercase: true }; @@ -90,7 +92,7 @@ describe('useVerifyPassword', () => { it('should return false if password does not meet the Accounts_Password_Policy_AtLeastOneUppercase policy', () => { mockUseSetting.mockImplementation((key: TPolicySettings) => { - const settings: Partial> = { + const settings: TPolicySettingValues = { Accounts_Password_Policy_Enabled: true, Accounts_Password_Policy_AtLeastOneUppercase: true }; @@ -103,7 +105,7 @@ describe('useVerifyPassword', () => { it('should return false if password does not meet the Accounts_Password_Policy_AtLeastOneSpecialCharacter policy', () => { mockUseSetting.mockImplementation((key: TPolicySettings) => { - const settings: Partial> = { + const settings: TPolicySettingValues = { Accounts_Password_Policy_Enabled: true, Accounts_Password_Policy_AtLeastOneSpecialCharacter: true }; @@ -116,7 +118,7 @@ describe('useVerifyPassword', () => { it('should return false if password does not meet the Accounts_Password_Policy_AtLeastOneNumber policy', () => { mockUseSetting.mockImplementation((key: TPolicySettings) => { - const settings: Partial> = { + const settings: TPolicySettingValues = { Accounts_Password_Policy_Enabled: true, Accounts_Password_Policy_AtLeastOneNumber: true }; @@ -129,7 +131,7 @@ describe('useVerifyPassword', () => { it('should return false if password does not meet the Accounts_Password_Policy_ForbidRepeatingCharacters policy', () => { mockUseSetting.mockImplementation((key: TPolicySettings) => { - const settings: Partial> = { + const settings: TPolicySettingValues = { Accounts_Password_Policy_Enabled: true, Accounts_Password_Policy_ForbidRepeatingCharacters: true, Accounts_Password_Policy_ForbidRepeatingCharactersCount: 3 @@ -143,7 +145,7 @@ describe('useVerifyPassword', () => { it('should return false if password does not meet the Accounts_Password_Policy_MinLength policy', () => { mockUseSetting.mockImplementation((key: TPolicySettings) => { - const settings: Partial> = { + const settings: TPolicySettingValues = { Accounts_Password_Policy_Enabled: true, Accounts_Password_Policy_MinLength: 3 }; @@ -156,7 +158,7 @@ describe('useVerifyPassword', () => { it('should return false if password does not meet the Accounts_Password_Policy_MaxLength policy', () => { mockUseSetting.mockImplementation((key: TPolicySettings) => { - const settings: Partial> = { + const settings: TPolicySettingValues = { Accounts_Password_Policy_Enabled: true, Accounts_Password_Policy_MaxLength: 4 }; diff --git a/app/lib/hooks/useVideoConf/index.tsx b/app/lib/hooks/useVideoConf/index.tsx index 82e67d9329e..41369ca8d9d 100644 --- a/app/lib/hooks/useVideoConf/index.tsx +++ b/app/lib/hooks/useVideoConf/index.tsx @@ -26,9 +26,13 @@ const handleErrors = (isAdmin: boolean, error: keyof typeof availabilityErrors) if (i18n.isTranslated(body) && i18n.isTranslated(header)) showErrorAlert(i18n.t(body), i18n.t(header)); }; -export const useVideoConf = ( - rid: string -): { showInitCallActionSheet: () => Promise; callEnabled: boolean; disabledTooltip?: boolean } => { +export interface IUseVideoConfResult { + showInitCallActionSheet: () => Promise; + callEnabled: boolean; + disabledTooltip?: boolean; +} + +export const useVideoConf = (rid: string): IUseVideoConfResult => { const user = useAppSelector(state => getUserSelector(state)); const serverVersion = useAppSelector(state => state.server.version); const { callEnabled, disabledTooltip, roomType } = useVideoConfCall(rid); diff --git a/app/lib/hooks/useVideoConf/useVideoConfCall.ts b/app/lib/hooks/useVideoConf/useVideoConfCall.ts index e792b33354e..4ac0ccbe3ed 100644 --- a/app/lib/hooks/useVideoConf/useVideoConfCall.ts +++ b/app/lib/hooks/useVideoConf/useVideoConfCall.ts @@ -10,9 +10,13 @@ import { isReadOnly } from '../../methods/helpers/isReadOnly'; import { useAppSelector } from '../useAppSelector'; import { usePermissions } from '../usePermissions'; -export const useVideoConfCall = ( - rid: string -): { callEnabled: boolean; disabledTooltip?: boolean; roomType?: SubscriptionType } => { +export interface IUseVideoConfCall { + callEnabled: boolean; + disabledTooltip?: boolean; + roomType?: SubscriptionType; +} + +export const useVideoConfCall = (rid: string): IUseVideoConfCall => { const [callEnabled, setCallEnabled] = useState(false); const [disabledTooltip, setDisabledTooltip] = useState(false); const [roomType, setRoomType] = useState(); diff --git a/app/lib/methods/checkSupportedVersions.ts b/app/lib/methods/checkSupportedVersions.ts index 8f98b1dc950..cc2a2b6181c 100644 --- a/app/lib/methods/checkSupportedVersions.ts +++ b/app/lib/methods/checkSupportedVersions.ts @@ -29,18 +29,20 @@ const getStatus = ({ expiration, message }: { expiration?: string; message?: TSV return 'supported'; }; +export interface ISupportedVersionsCheck { + status: TSVStatus; + message?: TSVMessage; + i18n?: TSVDictionary; + expiration?: string; +} + export const checkSupportedVersions = function ({ supportedVersions, serverVersion }: { supportedVersions?: ISupportedVersionsData; serverVersion: string; -}): { - status: TSVStatus; - message?: TSVMessage; - i18n?: TSVDictionary; - expiration?: string; -} { +}): ISupportedVersionsCheck { const serverVersionTilde = `~${serverVersion.split('.').slice(0, 2).join('.')}`; let sv: ISupportedVersionsData; if (!supportedVersions || supportedVersions.timestamp < builtInSupportedVersions.timestamp) { diff --git a/app/lib/methods/createDirectMessageSubscriptionStub.test.ts b/app/lib/methods/createDirectMessageSubscriptionStub.test.ts index 5236ec50012..b381e2632a8 100644 --- a/app/lib/methods/createDirectMessageSubscriptionStub.test.ts +++ b/app/lib/methods/createDirectMessageSubscriptionStub.test.ts @@ -46,7 +46,6 @@ describe('createDirectMessageSubscriptionStub', () => { let collectionWrites: any[]; let writeCallback: jest.Mock; let createMock: jest.Mock; - let mockedDb: { write: jest.Mock; get: jest.Mock }; beforeEach(() => { jest.clearAllMocks(); @@ -59,11 +58,10 @@ describe('createDirectMessageSubscriptionStub', () => { return Promise.resolve(); }); writeCallback = jest.fn((cb: () => Promise) => cb()); - mockedDb = { + (database as any).active = { write: writeCallback, get: jest.fn(() => ({ create: createMock })) }; - (database as any).active = mockedDb; (reduxStore.getState as jest.Mock).mockReturnValue(buildState()); (getSubscriptionByRoomId as jest.Mock).mockResolvedValue(null); diff --git a/app/lib/methods/getCustomEmojis.ts b/app/lib/methods/getCustomEmojis.ts index a3c986f3a7a..f46f25d1a6e 100644 --- a/app/lib/methods/getCustomEmojis.ts +++ b/app/lib/methods/getCustomEmojis.ts @@ -124,10 +124,7 @@ export function getCustomEmojis() { } return resolve(); } - const params: { updatedSince: string } = { updatedSince: '' }; - if (updatedSince) { - params.updatedSince = updatedSince; - } + const params = { updatedSince: updatedSince || '' }; // RC 0.75.0 const result = await sdk.get('emoji-custom.list', params); diff --git a/app/lib/methods/getPermissions.ts b/app/lib/methods/getPermissions.ts index cf38e96e466..ff87c9a9e38 100644 --- a/app/lib/methods/getPermissions.ts +++ b/app/lib/methods/getPermissions.ts @@ -182,11 +182,8 @@ export function getPermissions(): Promise { return resolve(); } - const params: { updatedSince?: string } = {}; const updatedSince = getUpdatedSince(allRecords); - if (updatedSince) { - params.updatedSince = updatedSince; - } + const params = updatedSince ? { updatedSince } : {}; // RC 0.73.0 const result = await sdk.get('permissions.listAll', params); diff --git a/app/lib/methods/getThreadName.test.ts b/app/lib/methods/getThreadName.test.ts index c21ad05529b..feec75267fd 100644 --- a/app/lib/methods/getThreadName.test.ts +++ b/app/lib/methods/getThreadName.test.ts @@ -44,14 +44,16 @@ const mockedDecryptMessage = Encryption.decryptMessage as jest.MockedFunction; // mimics watermelon rejecting an update prepared on a record another writer already touched +interface IFakeMessageRecord { + id: string; + tmsg: string | undefined; + stale: boolean; + prepareUpdate: jest.Mock; + update: jest.Mock; +} + const buildMessageRecord = (id: string) => { - const record: { - id: string; - tmsg: string | undefined; - stale: boolean; - prepareUpdate: jest.Mock; - update: jest.Mock; - } = { + const record: IFakeMessageRecord = { id, tmsg: undefined, stale: false, diff --git a/app/lib/methods/getUsersPresence.ts b/app/lib/methods/getUsersPresence.ts index 48b9ba536a5..d2e24597fbb 100644 --- a/app/lib/methods/getUsersPresence.ts +++ b/app/lib/methods/getUsersPresence.ts @@ -15,7 +15,11 @@ import userPreferences from './userPreferences'; import { NOTIFICATION_PRESENCE_CAP } from '../constants/notifications'; import { setNotificationPresenceCap } from '../../actions/app'; -export const _activeUsersSubTimeout: { activeUsersSubTimeout: boolean | ReturnType | number } = { +interface IActiveUsersSubTimeout { + activeUsersSubTimeout: boolean | ReturnType | number; +} + +export const _activeUsersSubTimeout: IActiveUsersSubTimeout = { activeUsersSubTimeout: false }; diff --git a/app/lib/methods/helpers/__tests__/hasOwnKey.test.ts b/app/lib/methods/helpers/__tests__/hasOwnKey.test.ts new file mode 100644 index 00000000000..fae68e7a2d4 --- /dev/null +++ b/app/lib/methods/helpers/__tests__/hasOwnKey.test.ts @@ -0,0 +1,26 @@ +import { hasOwnKey } from '../hasOwnKey'; + +describe('hasOwnKey', () => { + it('returns true for an own key', () => { + expect(hasOwnKey({ a: 1 }, 'a')).toBe(true); + }); + + it('returns false for an inherited key', () => { + expect(hasOwnKey({ a: 1 }, 'toString')).toBe(false); + }); + + it('returns false for a missing key', () => { + expect(hasOwnKey({ a: 1 }, 'b')).toBe(false); + }); + + it('returns true for an own key on a null-prototype object', () => { + const table = Object.assign(Object.create(null), { a: 1 }); + expect(hasOwnKey(table, 'a')).toBe(true); + }); + + it('narrows the key to a key of the object', () => { + const table = { online: 'Online', away: 'Away' }; + const key: string = 'away'; + expect(hasOwnKey(table, key) ? table[key] : 'unknown').toBe('Away'); + }); +}); diff --git a/app/lib/methods/helpers/hasOwnKey.ts b/app/lib/methods/helpers/hasOwnKey.ts new file mode 100644 index 00000000000..6b7c5e3d62a --- /dev/null +++ b/app/lib/methods/helpers/hasOwnKey.ts @@ -0,0 +1,2 @@ +export const hasOwnKey = (obj: T, key: string): key is keyof T & string => + Object.prototype.hasOwnProperty.call(obj, key); diff --git a/app/lib/methods/helpers/media.ts b/app/lib/methods/helpers/media.ts index 28d7d6a0441..2d99baff10a 100644 --- a/app/lib/methods/helpers/media.ts +++ b/app/lib/methods/helpers/media.ts @@ -1,5 +1,10 @@ import { type IShareAttachment } from '../../../definitions'; +export interface ICanUploadFileResult { + success: boolean; + error?: string; +} + export const canUploadFile = ({ file, allowList, @@ -10,7 +15,7 @@ export const canUploadFile = ({ allowList?: string; maxFileSize?: number; permissionToUploadFile: boolean; -}): { success: boolean; error?: string } => { +}): ICanUploadFileResult => { if (!(file && file.path)) { return { success: true }; } diff --git a/app/lib/methods/helpers/parseUrls.test.ts b/app/lib/methods/helpers/parseUrls.test.ts index bb57f7a3c65..2843d25392b 100644 --- a/app/lib/methods/helpers/parseUrls.test.ts +++ b/app/lib/methods/helpers/parseUrls.test.ts @@ -1,7 +1,30 @@ import { type IUrl, type IUrlFromServer } from '../../../definitions'; import parseUrls from './parseUrls'; -const tmpImageValidLink = { +interface IParseUrlsFixture { + urls: { + url: string; + ignoreParse?: boolean; + meta: Partial & { + msapplicationTileImage?: string; + msapplicationConfig?: string; + appleMobileWebAppTitle?: string; + }; + headers?: IUrlFromServer['headers']; + parsedUrl?: Partial>; + }[]; + expectedResult: { + _id: number; + title?: string; + description?: string; + image?: string; + url: string; + }[]; +} + +const parseFixture = (fixture: IParseUrlsFixture): IUrl[] => parseUrls(fixture.urls as IUrlFromServer[]); + +const tmpImageValidLink: IParseUrlsFixture = { urls: [ { url: 'https://meet.google.com/cbr-hysk-azn?pli=1&authuser=1', @@ -42,9 +65,9 @@ const tmpImageValidLink = { url: 'https://meet.google.com/cbr-hysk-azn?pli=1&authuser=1' } ] -} as { urls: IUrlFromServer[]; expectedResult: IUrl[] }; +}; -const tmpImagePointingToAnAsset = { +const tmpImagePointingToAnAsset: IParseUrlsFixture = { urls: [ { url: 'https://open.rocket.chat/', @@ -70,9 +93,9 @@ const tmpImagePointingToAnAsset = { url: 'https://open.rocket.chat/' } ] -} as unknown as { urls: IUrlFromServer[]; expectedResult: IUrl[] }; +}; -const tmpImagePointingToAnAssetThatStartsWithSlashWithoutParsedUrl = { +const tmpImagePointingToAnAssetThatStartsWithSlashWithoutParsedUrl: IParseUrlsFixture = { urls: [ { url: 'https://open.rocket.chat/', @@ -98,9 +121,9 @@ const tmpImagePointingToAnAssetThatStartsWithSlashWithoutParsedUrl = { url: 'https://open.rocket.chat/' } ] -} as unknown as { urls: IUrlFromServer[]; expectedResult: IUrl[] }; +}; -const tmpImagePointingToAnAssetThatStartsWithSlashWithParsedUrl = { +const tmpImagePointingToAnAssetThatStartsWithSlashWithParsedUrl: IParseUrlsFixture = { urls: [ { url: 'https://open.rocket.chat/', @@ -136,9 +159,9 @@ const tmpImagePointingToAnAssetThatStartsWithSlashWithParsedUrl = { url: 'https://open.rocket.chat/' } ] -} as unknown as { urls: IUrlFromServer[]; expectedResult: IUrl[] }; +}; -const tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithParsedUrl = { +const tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithParsedUrl: IParseUrlsFixture = { urls: [ { url: 'https://open.rocket.chat/', @@ -171,9 +194,9 @@ const tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithParsedUrl = { url: 'https://open.rocket.chat/' } ] -} as unknown as { urls: IUrlFromServer[]; expectedResult: IUrl[] }; +}; -const tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithoutParsedUrl = { +const tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithoutParsedUrl: IParseUrlsFixture = { urls: [ { url: 'https://open.rocket.chat/', @@ -199,36 +222,36 @@ const tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithoutParsedUrl = { url: 'https://open.rocket.chat/' } ] -} as unknown as { urls: IUrlFromServer[]; expectedResult: IUrl[] }; +}; describe('parseUrls function', () => { it('test when a tmp.image is a valid link', () => { - const result = parseUrls(tmpImageValidLink.urls); + const result = parseFixture(tmpImageValidLink); expect(result).toEqual(tmpImageValidLink.expectedResult); }); it('test when a tmp.image is assets/favicon_512.png', () => { - const result = parseUrls(tmpImagePointingToAnAsset.urls); + const result = parseFixture(tmpImagePointingToAnAsset); expect(result).toEqual(tmpImagePointingToAnAsset.expectedResult); }); it('test when a tmp.image is /assets/favicon_512.png and url with parsedUrl, parsedUrl.protocol and parsedUrl.host', () => { - const result = parseUrls(tmpImagePointingToAnAssetThatStartsWithSlashWithParsedUrl.urls); + const result = parseFixture(tmpImagePointingToAnAssetThatStartsWithSlashWithParsedUrl); expect(result).toEqual(tmpImagePointingToAnAssetThatStartsWithSlashWithParsedUrl.expectedResult); }); it('test when a tmp.image is /assets/favicon_512.png and url without parsedUrl', () => { - const result = parseUrls(tmpImagePointingToAnAssetThatStartsWithSlashWithoutParsedUrl.urls); + const result = parseFixture(tmpImagePointingToAnAssetThatStartsWithSlashWithoutParsedUrl); expect(result).toEqual(tmpImagePointingToAnAssetThatStartsWithSlashWithoutParsedUrl.expectedResult); }); it('test when a tmp.image is //assets/favicon_512.png and url with parsedUrl', () => { - const result = parseUrls(tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithParsedUrl.urls); + const result = parseFixture(tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithParsedUrl); expect(result).toEqual(tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithParsedUrl.expectedResult); }); it('test when a tmp.image is //assets/favicon_512.png and url without parsedUrl', () => { - const result = parseUrls(tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithoutParsedUrl.urls); + const result = parseFixture(tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithoutParsedUrl); expect(result).toEqual(tmpImagePointingToAnAssetThatStartsWithDoubleSlashWithoutParsedUrl.expectedResult); }); }); diff --git a/app/lib/methods/helpers/sslPinning.ts b/app/lib/methods/helpers/sslPinning.ts index 865b8bde71d..ba778b3ef90 100644 --- a/app/lib/methods/helpers/sslPinning.ts +++ b/app/lib/methods/helpers/sslPinning.ts @@ -82,7 +82,7 @@ const RCSSLPinning = Platform.select({ }), setCertificate: (name: string, server: string) => { if (name) { - const certificate = UserPreferences.getMap(name) as ICertificate; + const certificate = UserPreferences.getMap(name); if (certificate) { persistCertificate(server, name, certificate.password); SSLPinning?.setCertificate(server, certificate.path, certificate.password); diff --git a/app/lib/methods/helpers/theme.ts b/app/lib/methods/helpers/theme.ts index b54de5c4ba0..f27e07478d5 100644 --- a/app/lib/methods/helpers/theme.ts +++ b/app/lib/methods/helpers/theme.ts @@ -12,7 +12,7 @@ import { isAndroid } from './deviceInfo'; let themeListener: { remove: () => void } | null; export const initialTheme = (): IThemePreference => { - const theme = UserPreferences.getMap(THEME_PREFERENCES_KEY) as IThemePreference; + const theme = UserPreferences.getMap(THEME_PREFERENCES_KEY); const initialTheme: IThemePreference = { currentTheme: defaultTheme(), darkLevel: 'black' diff --git a/app/lib/methods/loadMessagesForRoom.test.ts b/app/lib/methods/loadMessagesForRoom.test.ts index 8cd7006a8a7..d28862de74f 100644 --- a/app/lib/methods/loadMessagesForRoom.test.ts +++ b/app/lib/methods/loadMessagesForRoom.test.ts @@ -48,7 +48,7 @@ const buildMessage = ({ id, ts, t }: { id: string; ts: string; t?: string }) => _id: id, rid: 'ROOM_ID', ts, - ...(t ? { t } : {}) + ...(t && { t }) }) as any; describe('loadMessagesForRoom', () => { diff --git a/app/lib/methods/loadSurroundingMessages.ts b/app/lib/methods/loadSurroundingMessages.ts index ecb5b3c7e0f..bed1f1995be 100644 --- a/app/lib/methods/loadSurroundingMessages.ts +++ b/app/lib/methods/loadSurroundingMessages.ts @@ -12,8 +12,8 @@ import { generateLoadMoreId } from './helpers/generateLoadMoreId'; const COUNT = 50; -export function loadSurroundingMessages({ messageId, rid }: { messageId: string; rid: string }) { - return new Promise(async (resolve, reject) => { +export function loadSurroundingMessages({ messageId, rid }: { messageId: string; rid: string }): Promise { + return new Promise(async (resolve, reject) => { try { const data = await sdk.methodCallWrapper('loadSurroundingMessages', { _id: messageId, rid }, COUNT); let messages: IMessage[] = EJSON.fromJSONValue(data?.messages); diff --git a/app/lib/methods/roomTypeToApiType.ts b/app/lib/methods/roomTypeToApiType.ts index b84eb7242c5..a98eb9f8073 100644 --- a/app/lib/methods/roomTypeToApiType.ts +++ b/app/lib/methods/roomTypeToApiType.ts @@ -16,12 +16,12 @@ type ApiTypes = T extends 'c' ? ETypes.Channels : never; -export const types: { [K in RoomTypes]: ApiTypes } = { +export const types = { c: ETypes.Channels, d: ETypes.Im, p: ETypes.Groups, l: ETypes.Channels -}; +} satisfies { [K in RoomTypes]: ApiTypes }; export const roomTypeToApiType = (t: T) => types[t]; diff --git a/app/lib/methods/sendMessage.test.ts b/app/lib/methods/sendMessage.test.ts index 05c69ab8c78..a0222ef9ee1 100644 --- a/app/lib/methods/sendMessage.test.ts +++ b/app/lib/methods/sendMessage.test.ts @@ -75,7 +75,11 @@ jest.mock('@nozbe/watermelondb/RawRecord', () => ({ sanitizedRaw: (raw: unknown) => raw })); -const mockEncryptionGate: { promise: Promise | null } = { promise: null }; +interface IEncryptionGate { + promise: Promise | null; +} + +const mockEncryptionGate: IEncryptionGate = { promise: null }; jest.mock('../encryption', () => ({ Encryption: { encryptMessage: jest.fn(async (message: unknown) => { diff --git a/app/lib/methods/setUser.ts b/app/lib/methods/setUser.ts index 3d19cd8bb5e..da77b4e1208 100644 --- a/app/lib/methods/setUser.ts +++ b/app/lib/methods/setUser.ts @@ -35,7 +35,11 @@ export interface IActiveUsers { } export const _activeUsers = { activeUsers: {} as IActiveUsers }; -export const _setUserTimer: { setUserTimer: null | ReturnType } = { setUserTimer: null }; +interface ISetUserTimer { + setUserTimer: null | ReturnType; +} + +export const _setUserTimer: ISetUserTimer = { setUserTimer: null }; export function _setUser(ddpMessage: IActiveUsers): void { _activeUsers.activeUsers = _activeUsers.activeUsers || {}; diff --git a/app/lib/methods/subscriptions/room.resumeSync.test.ts b/app/lib/methods/subscriptions/room.resumeSync.test.ts index 6d5004403f5..6fe82ed777b 100644 --- a/app/lib/methods/subscriptions/room.resumeSync.test.ts +++ b/app/lib/methods/subscriptions/room.resumeSync.test.ts @@ -48,9 +48,15 @@ const missedMessage = { u: { _id: 'user2', username: 'user2' } }; -const syncMessagesResponse = ( - updated: unknown[] -): { result: { updated: unknown[]; deleted: unknown[]; cursor: { next: number | null } } } => ({ +interface ISyncMessagesResponse { + result: { + updated: unknown[]; + deleted: unknown[]; + cursor: { next: number | null }; + }; +} + +const syncMessagesResponse = (updated: unknown[]): ISyncMessagesResponse => ({ result: { updated, deleted: [], cursor: { next: null } } }); diff --git a/app/lib/methods/subscriptions/room.test.ts b/app/lib/methods/subscriptions/room.test.ts index 7156e1da294..727f27eccc1 100644 --- a/app/lib/methods/subscriptions/room.test.ts +++ b/app/lib/methods/subscriptions/room.test.ts @@ -183,12 +183,12 @@ describe('RoomSubscription', () => { const messageRecord = makeFakeRecord(`messages#${_id}`); const threadRecord = makeFakeRecord(`threads#${_id}`); const threadMessageRecord = makeFakeRecord(`thread_messages#${_id}`); - const collections: Record = { + const collections = { messages: { find: () => Promise.resolve(messageRecord) }, threads: { find: () => Promise.resolve(threadRecord) }, thread_messages: { find: () => Promise.resolve(threadMessageRecord) } }; - mockDbGet.mockImplementation((name: string) => collections[name]); + mockDbGet.mockImplementation((name: keyof typeof collections) => collections[name]); const db = (database as any).active; diff --git a/app/lib/methods/userPreferences.ts b/app/lib/methods/userPreferences.ts index 045a22d50ee..898e6848e70 100644 --- a/app/lib/methods/userPreferences.ts +++ b/app/lib/methods/userPreferences.ts @@ -92,11 +92,12 @@ class UserPreferences { this.mmkv = MMKV_INSTANCE; } - private tryParseJson(value: string): unknown { + private tryParseBool(value: string): boolean | null { try { - return JSON.parse(value); + const parsed = JSON.parse(value); + return typeof parsed === 'boolean' ? parsed : null; } catch { - return undefined; + return null; } } @@ -116,8 +117,7 @@ class UserPreferences { try { const storedString = this.mmkv.getString(key); if (storedString !== undefined) { - const parsed = this.tryParseJson(storedString); - return typeof parsed === 'boolean' ? parsed : null; + return this.tryParseBool(storedString); } return this.mmkv.getBoolean(key) ?? null; } catch { @@ -129,7 +129,7 @@ class UserPreferences { this.mmkv.set(key, value); } - getMap(key: string): object | null { + getMap(key: string): T | null { try { const jsonString = this.mmkv.getString(key); return jsonString ? JSON.parse(jsonString) : null; @@ -138,7 +138,7 @@ class UserPreferences { } } - setMap(key: string, value: object): void { + setMap(key: string, value: T): void { this.mmkv.set(key, JSON.stringify(value)); } diff --git a/app/lib/methods/userPreferencesMethods.ts b/app/lib/methods/userPreferencesMethods.ts index 8942ca002c0..fe905258ac5 100644 --- a/app/lib/methods/userPreferencesMethods.ts +++ b/app/lib/methods/userPreferencesMethods.ts @@ -4,11 +4,10 @@ import userPreferences from './userPreferences'; const SORT_PREFS_KEY = 'RC_SORT_PREFS_KEY'; export function getSortPreferences() { - return userPreferences.getMap(SORT_PREFS_KEY); + return userPreferences.getMap>(SORT_PREFS_KEY); } export function saveSortPreference(param: Partial) { - let prefs = getSortPreferences(); - prefs = { ...prefs, ...param } as object; - return userPreferences.setMap(SORT_PREFS_KEY, prefs); + const prefs = getSortPreferences(); + return userPreferences.setMap(SORT_PREFS_KEY, { ...prefs, ...param }); } diff --git a/app/lib/notifications/index.ts b/app/lib/notifications/index.ts index 47707cf964c..9e6a1c01704 100644 --- a/app/lib/notifications/index.ts +++ b/app/lib/notifications/index.ts @@ -16,6 +16,21 @@ interface IEjson { messageId: string; } +const pathSegmentByRoomType = (roomType: string): string | undefined => { + switch (roomType) { + case SubscriptionType.CHANNEL: + return 'channel'; + case SubscriptionType.DIRECT: + return 'direct'; + case SubscriptionType.GROUP: + return 'group'; + case SubscriptionType.OMNICHANNEL: + return 'channels'; + default: + return undefined; + } +}; + export const onNotification = (push: INotification): void => { const identifier = String(push?.payload?.action?.identifier); @@ -50,12 +65,6 @@ export const onNotification = (push: INotification): void => { return; } const { rid, name, sender, type, host, messageId }: IEjson = notification; - const types: Record = { - c: 'channel', - d: 'direct', - p: 'group', - l: 'channels' - }; let roomName = name; if (type === SubscriptionType.DIRECT) { roomName = sender?.username ?? name; @@ -67,7 +76,7 @@ export const onNotification = (push: INotification): void => { host, rid, messageId, - path: `${types[type]}/${roomName}` + path: `${pathSegmentByRoomType(type)}/${roomName}` }; store.dispatch(deepLinkingOpen(params)); return; diff --git a/app/lib/services/restApi.test.ts b/app/lib/services/restApi.test.ts index 8dc0dcf8fba..fbf984c1e37 100644 --- a/app/lib/services/restApi.test.ts +++ b/app/lib/services/restApi.test.ts @@ -5,7 +5,8 @@ import { mediaCallsStateSignals } from './restApi'; const mockSdkGet = jest.fn(); const mockSdkPost = jest.fn(); -let mockSdkCurrent: unknown = {}; +const mockSdkDriver = { get: mockSdkGet, post: mockSdkPost }; +let mockSdkCurrent: typeof mockSdkDriver | null = mockSdkDriver; jest.mock('./sdk', () => ({ __esModule: true, @@ -129,19 +130,19 @@ describe('registerPushToken', () => { beforeEach(() => { jest.clearAllMocks(); mockSdkPost.mockResolvedValue(undefined); - mockSdkCurrent = {}; + mockSdkCurrent = mockSdkDriver; }); it('does not post when SDK is not initialized, and a later call after init posts', async () => { const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios'); getToken.mockReturnValue('apns-token'); getVoip.mockReturnValue('voip-token'); - mockSdkCurrent = undefined; + mockSdkCurrent = null; await registerPushToken(); expect(mockSdkPost).not.toHaveBeenCalled(); - mockSdkCurrent = {}; + mockSdkCurrent = mockSdkDriver; await registerPushToken(); expect(mockSdkPost).toHaveBeenCalledTimes(1); }); diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index c6f087b8209..3bc76261d4a 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -882,13 +882,10 @@ export const getThreadsList = ({ rid, count, offset, text }: { rid: string; coun rid, count, offset - } as { rid: string; count: number; offset: number; text?: string }; - if (text) { - params.text = text; - } + }; // RC 1.0 - return sdk.get('chat.getThreadsList', params); + return sdk.get('chat.getThreadsList', text ? { ...params, text } : params); }; export const getSyncThreadsList = ({ rid, updatedSince }: { rid: string; updatedSince: string }) => @@ -1297,7 +1294,10 @@ export const getSupportedVersionsCloud = (uniqueId?: string, domain?: string) => export const mediaCallsStateSignals = async (contractId: string): Promise<{ signals: ServerMediaSignal[]; success: boolean }> => { try { const result = await ( - sdk.get as unknown as (path: string, params?: object) => Promise<{ signals: ServerMediaSignal[]; success: boolean }> + sdk.get as unknown as ( + path: string, + params?: { contractId: string } + ) => Promise<{ signals: ServerMediaSignal[]; success: boolean }> )('media-calls.stateSignals', { contractId }); return result; } catch { diff --git a/app/lib/services/voip/MediaCallEvents.ios.test.ts b/app/lib/services/voip/MediaCallEvents.ios.test.ts index 8f5a429d7a0..57abb691f42 100644 --- a/app/lib/services/voip/MediaCallEvents.ios.test.ts +++ b/app/lib/services/voip/MediaCallEvents.ios.test.ts @@ -165,7 +165,7 @@ function buildIncomingPayload(overrides: Partial = {}): VoipPayload } const activeCallBase = { - call: {} as object, + call: {}, callId: 'uuid-1', nativeAcceptedCallId: null as string | null }; diff --git a/app/lib/services/voip/MediaCallEvents.test.ts b/app/lib/services/voip/MediaCallEvents.test.ts index 856dbb18cd8..ada9c3010d3 100644 --- a/app/lib/services/voip/MediaCallEvents.test.ts +++ b/app/lib/services/voip/MediaCallEvents.test.ts @@ -102,7 +102,7 @@ function getToggleHoldHandler(): (payload: { hold: boolean; callUUID: string }) /** Minimal store slice: handler only runs hold logic when call + matching callId/native id exist. */ const activeCallBase = { - call: {} as object, + call: {}, callId: 'uuid-1', nativeAcceptedCallId: null as string | null }; diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index 564bddb47d3..62b399b90a2 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -38,17 +38,31 @@ const mockGetUidDirectMessage = jest.mocked(getUidDirectMessage); const mockCallStoreReset = jest.fn(); const mockSetRoomId = jest.fn(); const mockSetDirection = jest.fn(); -const mockUseCallStoreGetState = jest.fn(() => ({ - reset: mockCallStoreReset, - setCall: jest.fn(), - setRoomId: mockSetRoomId, - setDirection: mockSetDirection, - resetNativeCallId: jest.fn(), - call: null as unknown, - callId: null as string | null, - nativeAcceptedCallId: null as string | null, - roomId: null as string | null -})); +interface IMockCallStoreState { + reset: jest.Mock; + setCall: jest.Mock; + setRoomId: jest.Mock; + setDirection: jest.Mock; + resetNativeCallId: jest.Mock; + call: IClientMediaCall | null; + callId: string | null; + nativeAcceptedCallId: string | null; + roomId: string | null; +} + +const mockUseCallStoreGetState = jest.fn( + (): IMockCallStoreState => ({ + reset: mockCallStoreReset, + setCall: jest.fn(), + setRoomId: mockSetRoomId, + setDirection: mockSetDirection, + resetNativeCallId: jest.fn(), + call: null, + callId: null, + nativeAcceptedCallId: null, + roomId: null + }) +); jest.mock('./useCallStore', () => ({ useCallStore: { diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index b41b1cdef6e..8f4d1a0cccc 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -3,6 +3,7 @@ import { Platform } from 'react-native'; import RNCallKeep from 'react-native-callkeep'; import InCallManager from 'react-native-incall-manager'; +import type * as helpers from '../../methods/helpers'; import NativeVoipModule from '../../native/NativeVoip'; import { pendingHangups } from './pendingHangups'; import { useCallStore } from './useCallStore'; @@ -61,9 +62,9 @@ jest.mock('../../native/NativeVoip', () => ({ // Re-evaluate `isIOS` per-test (the helper module computes it once at import time from Platform.OS, // so we replace it with a getter that reflects the current Platform.OS in the test). jest.mock('../../methods/helpers', () => { - const actual = jest.requireActual('../../methods/helpers'); + const actual = jest.requireActual('../../methods/helpers'); const { Platform } = jest.requireActual('react-native'); - const proxy: Record = { ...actual }; + const proxy = { ...actual }; Object.defineProperty(proxy, 'isIOS', { get() { return Platform.OS === 'ios'; diff --git a/app/reducers/roles.ts b/app/reducers/roles.ts index 9bbcbf077aa..807f47cf28e 100644 --- a/app/reducers/roles.ts +++ b/app/reducers/roles.ts @@ -10,6 +10,7 @@ export default function roles(state = initialState, action: IActionRoles): IRole case ROLES.SET: return action.roles; case ROLES.UPDATE: + // oxlint-disable-next-line anti-slop/no-known-value-widening -- IRoles keys are server role ids discovered at runtime return { ...state, [action.payload.id]: action.payload.desc || action.payload.id @@ -17,6 +18,7 @@ export default function roles(state = initialState, action: IActionRoles): IRole case ROLES.REMOVE: { const newState = { ...state }; delete newState[action.payload.id]; + // oxlint-disable-next-line anti-slop/no-known-value-widening -- IRoles keys are server role ids discovered at runtime return newState; } default: diff --git a/app/reducers/share.test.ts b/app/reducers/share.test.ts index 22b3a534f16..aea68c781b2 100644 --- a/app/reducers/share.test.ts +++ b/app/reducers/share.test.ts @@ -9,7 +9,7 @@ describe('test share reducer', () => { }); it('should return correctly updated state after calling setParams action', () => { - const params: Record = { + const params = { mediaUris: 'test' }; mockedStore.dispatch(shareSetParams(params)); @@ -21,7 +21,7 @@ describe('test share reducer', () => { }); it('should reset params to an empty object', () => { - const params: Record = {}; + const params = {}; mockedStore.dispatch(shareSetParams(params)); const state = mockedStore.getState().share; expect(state).toEqual(initialState); diff --git a/app/views/CallView/components/Dialpad/DialpadContext.tsx b/app/views/CallView/components/Dialpad/DialpadContext.tsx index 0bbaf08deee..9b96ddfac17 100644 --- a/app/views/CallView/components/Dialpad/DialpadContext.tsx +++ b/app/views/CallView/components/Dialpad/DialpadContext.tsx @@ -1,7 +1,7 @@ import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react'; import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av'; -const DTMF_ASSETS: Record> = { +const DTMF_ASSETS = { '0': require('../../../../containers/Ringer/dtmf/digit-0.mp3'), '1': require('../../../../containers/Ringer/dtmf/digit-1.mp3'), '2': require('../../../../containers/Ringer/dtmf/digit-2.mp3'), @@ -14,7 +14,7 @@ const DTMF_ASSETS: Record> = { '9': require('../../../../containers/Ringer/dtmf/digit-9.mp3'), '*': require('../../../../containers/Ringer/dtmf/digit-star.mp3'), '#': require('../../../../containers/Ringer/dtmf/digit-pound.mp3') -}; +} satisfies Record>; interface DialpadContextValue { playTone: (digit: string) => void; diff --git a/app/views/CallView/index.test.tsx b/app/views/CallView/index.test.tsx index 70e8fec9a7a..a65f29bc394 100644 --- a/app/views/CallView/index.test.tsx +++ b/app/views/CallView/index.test.tsx @@ -46,7 +46,7 @@ jest.mock('../../lib/native/NativeVoip', () => ({ jest.mock('../../lib/methods/helpers', () => { const actual = jest.requireActual('../../lib/methods/helpers'); const { Platform: RNPlatform } = jest.requireActual('react-native'); - const proxy: Record = { ...actual }; + const proxy = { ...actual }; Object.defineProperty(proxy, 'isIOS', { get() { return RNPlatform.OS === 'ios'; diff --git a/app/views/CallView/useCallLayoutMode.ts b/app/views/CallView/useCallLayoutMode.ts index 5126f08c3f6..7ef88c1616f 100644 --- a/app/views/CallView/useCallLayoutMode.ts +++ b/app/views/CallView/useCallLayoutMode.ts @@ -2,7 +2,11 @@ import { useResponsiveLayout } from '../../lib/hooks/useResponsiveLayout/useResp import { MIN_WIDTH_MASTER_DETAIL_LAYOUT } from '../../lib/constants/tablet'; import { type LayoutMode } from './types'; -export const useCallLayoutMode = (): { layoutMode: LayoutMode } => { +interface IUseCallLayoutModeResult { + layoutMode: LayoutMode; +} + +export const useCallLayoutMode = (): IUseCallLayoutModeResult => { const { width } = useResponsiveLayout(); return { layoutMode: width >= MIN_WIDTH_MASTER_DETAIL_LAYOUT ? 'wide' : 'narrow' }; }; diff --git a/app/views/ChangePasswordView/index.tsx b/app/views/ChangePasswordView/index.tsx index 7e2ff69b63c..bb8c8134a77 100644 --- a/app/views/ChangePasswordView/index.tsx +++ b/app/views/ChangePasswordView/index.tsx @@ -63,7 +63,7 @@ const ChangePasswordView = ({ navigation }: IChangePasswordViewProps) => { const { colors } = useTheme(); const fromProfileView = isFromRoute(navigation, 'ProfileView'); - const validationSchema = yup.object().shape({ + const validationSchema = yup.object({ currentPassword: yup.string().required(`${I18n.t('Field_is_required', { field: I18n.t('Current_password') })}`), newPassword: yup.string().required(`${I18n.t('Field_is_required', { field: I18n.t('New_Password') })}`), confirmNewPassword: yup diff --git a/app/views/CreateChannelView/index.tsx b/app/views/CreateChannelView/index.tsx index b8b3849408d..c755ccb5955 100644 --- a/app/views/CreateChannelView/index.tsx +++ b/app/views/CreateChannelView/index.tsx @@ -49,7 +49,7 @@ export interface IFormData { } const CreateChannelView = () => { - const schema = yup.object().shape({ + const schema = yup.object({ channelName: yup.string().trim().required(I18n.t('Channel_name_required')) }); diff --git a/app/views/CreateDiscussionView/index.tsx b/app/views/CreateDiscussionView/index.tsx index e84a11ef711..992436add84 100644 --- a/app/views/CreateDiscussionView/index.tsx +++ b/app/views/CreateDiscussionView/index.tsx @@ -18,7 +18,7 @@ import styles from './styles'; import SelectChannel from './SelectChannel'; import { type ICreateChannelViewProps, type IResult, type IError } from './interfaces'; import { type ISearchLocal, type ISubscription } from '../../definitions'; -import { E2E_ROOM_TYPES } from '../../lib/constants/keys'; +import { isE2ERoomType } from '../../lib/constants/keys'; import { getRoomTitle } from '../../lib/methods/helpers'; import * as List from '../../containers/List'; import Switch from '../../containers/Switch'; @@ -32,7 +32,7 @@ import SelectedUsers from '../../containers/SelectedUsers'; import { type ISelectedUser } from '../../reducers/selectedUsers'; const CreateDiscussionView = ({ route, navigation }: ICreateChannelViewProps) => { - const schema = yup.object().shape({ + const schema = yup.object({ name: yup.string().required(I18n.t('Discussion_name_required')) }); const { colors } = useTheme(); @@ -82,7 +82,7 @@ const CreateDiscussionView = ({ route, navigation }: ICreateChannelViewProps) => const inputValues = watch(); const prevLoading = useRef(loading); - const isEncryptionEnabled = encryptionEnabled && E2E_ROOM_TYPES[channel?.t]; + const isEncryptionEnabled = encryptionEnabled && isE2ERoomType(channel?.t); const selectChannel = ({ value }: { value: ISearchLocal }) => { logEvent(events.CD_SELECT_CHANNEL); diff --git a/app/views/ForgotPasswordView.tsx b/app/views/ForgotPasswordView.tsx index 39158d03775..522c04b3fb3 100644 --- a/app/views/ForgotPasswordView.tsx +++ b/app/views/ForgotPasswordView.tsx @@ -17,7 +17,7 @@ import { showErrorAlert } from '../lib/methods/helpers'; import { events, logEvent } from '../lib/methods/helpers/log'; import sharedStyles from './Styles'; -const schema = yup.object().shape({ +const schema = yup.object({ email: yup.string().email().required() }); diff --git a/app/views/LanguageView/index.tsx b/app/views/LanguageView/index.tsx index 316f8d3b38d..1dd40d78022 100644 --- a/app/views/LanguageView/index.tsx +++ b/app/views/LanguageView/index.tsx @@ -62,16 +62,12 @@ const LanguageView = () => { const changeLanguage = async (language: string) => { logEvent(events.LANG_SET_LANGUAGE); - const params: { language?: string } = {}; - - // language - if (languageDefault !== language) { - params.language = language; - } + const changedLanguage = languageDefault === language ? undefined : language; + const params = changedLanguage ? { language: changedLanguage } : {}; try { await saveUserPreferences(params); - dispatch(setUser({ language: params.language })); + dispatch(setUser({ language: changedLanguage })); const serversDB = database.servers; const usersCollection = serversDB.get('users'); @@ -79,7 +75,7 @@ const LanguageView = () => { try { const userRecord = await usersCollection.find(id); await userRecord.update(record => { - record.language = params.language; + record.language = changedLanguage; }); } catch (e) { logEvent(events.LANG_SET_LANGUAGE_F); diff --git a/app/views/LoginView/UserForm.tsx b/app/views/LoginView/UserForm.tsx index ebca26f2f73..d67ce8dc395 100644 --- a/app/views/LoginView/UserForm.tsx +++ b/app/views/LoginView/UserForm.tsx @@ -25,7 +25,7 @@ interface ISubmit { password: string; } -const schema = yup.object().shape({ +const schema = yup.object({ user: yup.string().required(), password: yup.string().required() }); diff --git a/app/views/ProfileView/components/DeleteAccountActionSheetContent/getTranslations.ts b/app/views/ProfileView/components/DeleteAccountActionSheetContent/getTranslations.ts index ebeee9b6ad4..9b31e927626 100644 --- a/app/views/ProfileView/components/DeleteAccountActionSheetContent/getTranslations.ts +++ b/app/views/ProfileView/components/DeleteAccountActionSheetContent/getTranslations.ts @@ -1,12 +1,17 @@ import i18n from '../../../../i18n'; +export interface IDeleteAccountTranslations { + changeOwnerRooms: string; + removedRooms: string; +} + export const getTranslations = ({ shouldChangeOwner, shouldBeRemoved }: { shouldChangeOwner: string[]; shouldBeRemoved: string[]; -}): { changeOwnerRooms: string; removedRooms: string } => { +}): IDeleteAccountTranslations => { let changeOwnerRooms = ''; if (shouldChangeOwner.length) { if (shouldChangeOwner.length === 1) { diff --git a/app/views/ProfileView/index.tsx b/app/views/ProfileView/index.tsx index 451fe06adc9..0796850f6f8 100644 --- a/app/views/ProfileView/index.tsx +++ b/app/views/ProfileView/index.tsx @@ -51,7 +51,7 @@ interface IProfileViewProps { navigation: NativeStackNavigationProp; } const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { - const validationSchema = yup.object().shape({ + const validationSchema = yup.object({ name: yup.string().required(I18n.t('Name_required')), email: yup.string().email(I18n.t('Email_must_be_a_valid_email')).required(I18n.t('Email_required')), username: yup.string().required(I18n.t('Username_required')) diff --git a/app/views/RegisterView/index.tsx b/app/views/RegisterView/index.tsx index 9be41c87d77..4fc81026d21 100644 --- a/app/views/RegisterView/index.tsx +++ b/app/views/RegisterView/index.tsx @@ -34,7 +34,7 @@ type RegisterViewProps = StaticScreenProps<{ title: string; username?: string }> const RegisterView = ({ route }: RegisterViewProps) => { const navigation = useNavigation>(); - const validationSchema = yup.object().shape({ + const validationSchema = yup.object({ name: yup.string().required(`${I18n.t('Field_is_required', { field: I18n.t('Full_name') })}`), email: yup .string() diff --git a/app/views/ReportUserView/index.tsx b/app/views/ReportUserView/index.tsx index 00b0ef02ec6..6c2bbfa6cbc 100644 --- a/app/views/ReportUserView/index.tsx +++ b/app/views/ReportUserView/index.tsx @@ -34,7 +34,7 @@ interface ISubmit { description: string; } -const schema = yup.object().shape({ +const schema = yup.object({ description: yup.string().trim().required(I18n.t('Report_reason_required')) }); diff --git a/app/views/RoomActionsView/index.tsx b/app/views/RoomActionsView/index.tsx index d25bebe310a..ff3a08de47d 100644 --- a/app/views/RoomActionsView/index.tsx +++ b/app/views/RoomActionsView/index.tsx @@ -38,7 +38,7 @@ import log, { events, logEvent } from '../../lib/methods/helpers/log'; import Touch from '../../containers/Touch'; import styles from './styles'; import { ERoomType } from '../../definitions/ERoomType'; -import { E2E_ROOM_TYPES } from '../../lib/constants/keys'; +import { isE2ERoomType } from '../../lib/constants/keys'; import { themes } from '../../lib/constants/colors'; import { getPermalinkChannel } from '../../lib/methods/getPermalinks'; import { @@ -843,7 +843,7 @@ class RoomActionsView extends Component diff --git a/app/views/RoomActionsView/styles.ts b/app/views/RoomActionsView/styles.ts index 723483f757b..6348ad6ed8f 100644 --- a/app/views/RoomActionsView/styles.ts +++ b/app/views/RoomActionsView/styles.ts @@ -29,7 +29,5 @@ export default StyleSheet.create({ flexDirection: 'row', alignItems: 'center' }, - actionIndicator: { - ...(I18nManager.isRTL ? { transform: [{ rotate: '180deg' }] } : {}) - } + actionIndicator: I18nManager.isRTL ? { transform: [{ rotate: '180deg' }] } : {} }); diff --git a/app/views/RoomInfoEditView/index.tsx b/app/views/RoomInfoEditView/index.tsx index 40dfb958d47..521189dad15 100644 --- a/app/views/RoomInfoEditView/index.tsx +++ b/app/views/RoomInfoEditView/index.tsx @@ -46,7 +46,7 @@ const dirtyOptions: SetValueConfig = { shouldDirty: true }; -const schema = yup.object().shape({ +const schema = yup.object({ name: yup.string().required(I18n.t('Name_required')) }); diff --git a/app/views/RoomInfoView/index.test.tsx b/app/views/RoomInfoView/index.test.tsx index 671b66d9312..ef0a7939772 100644 --- a/app/views/RoomInfoView/index.test.tsx +++ b/app/views/RoomInfoView/index.test.tsx @@ -8,7 +8,6 @@ import { initStore } from '../../lib/store/auxStore'; import { setUser } from '../../actions/login'; import { getUserInfo, toggleBlockUser } from '../../lib/services/restApi'; -let mockRouteParams: Record = {}; const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ @@ -83,6 +82,16 @@ const dmRoom = { blocker: false }; +const dmRouteParams = { + rid: 'dm-rid', + t: 'd', + fromRid: 'dm-rid', + room: dmRoom, + member: { username: 'other.user', status: 'online' } +}; + +let mockRouteParams = dmRouteParams; + describe('RoomInfoView block/ignore user', () => { beforeAll(() => { initStore(mockedStore); @@ -92,13 +101,7 @@ describe('RoomInfoView block/ignore user', () => { beforeEach(() => { jest.clearAllMocks(); // member arrives from RoomActionsView possibly without _id (its own fetch may not have resolved yet) - mockRouteParams = { - rid: 'dm-rid', - t: 'd', - fromRid: 'dm-rid', - room: dmRoom, - member: { username: 'other.user', status: 'online' } - }; + mockRouteParams = dmRouteParams; }); it('blocks the DM user even when member param has no _id yet', async () => { diff --git a/app/views/RoomInfoView/index.tsx b/app/views/RoomInfoView/index.tsx index 1e8b14ea772..4f198a73d27 100644 --- a/app/views/RoomInfoView/index.tsx +++ b/app/views/RoomInfoView/index.tsx @@ -39,6 +39,20 @@ type TRoomInfoViewNavigationProp = CompositeNavigationProp< type TRoomInfoViewRouteProp = RouteProp; +interface IUserAgentInfo { + os: string; + browser: string; +} + +const parseUserAgent = (userAgent: string): IUserAgentInfo => { + const ua = new UAParser(); + ua.setUA(userAgent); + return { + os: `${ua.getOS().name} ${ua.getOS().version}`, + browser: `${ua.getBrowser().name} ${ua.getBrowser().version}` + }; +}; + const RoomInfoView = (): ReactElement => { const { params: { rid, t, fromRid, member, room: roomParam, showCloseModal, itsMe } @@ -138,14 +152,8 @@ const RoomInfoView = (): ReactElement => { const result = await getVisitorInfo(room.visitor._id); if (result.success) { const { visitor } = result; - const params: { os?: string; browser?: string } = {}; - if (visitor.userAgent) { - const ua = new UAParser(); - ua.setUA(visitor.userAgent); - params.os = `${ua.getOS().name} ${ua.getOS().version}`; - params.browser = `${ua.getBrowser().name} ${ua.getBrowser().version}`; - } - setRoomUser({ ...visitor, ...params }); + const { userAgent } = visitor; + setRoomUser(userAgent ? { ...visitor, ...parseUserAgent(userAgent) } : visitor); setHeader(); } } diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index 78b281d4493..a3f4fcf9fe4 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -2,6 +2,25 @@ import { Component, type ComponentType, createRef, type MutableRefObject } from import { Platform, StyleSheet, findNodeHandle, type LayoutChangeEvent, type ScrollViewProps, processColor } from 'react-native'; import codegenNativeCommands from 'react-native/Libraries/Utilities/codegenNativeCommands'; +interface Point { + x: number; + y: number; +} + +const pointsDiffer = require('react-native/Libraries/Utilities/differ/pointsDiffer').default as ( + a: Point | null, + b: Point | null +) => boolean; + +type ViewAttribute = true | { diff: typeof pointsDiffer } | { process: typeof processColor }; + +interface ViewConfig { + uiViewClassName: string; + bubblingEventTypes: Record; + directEventTypes: Record; + validAttributes: Record; +} + // NativeComponentRegistry.get() registers components as proper Fabric host components. // requireNativeComponent() uses the legacy interop layer, which breaks Fabric's touch // event routing: when Fabric-rendered children (FlatList cells with pressable elements) @@ -9,14 +28,9 @@ import codegenNativeCommands from 'react-native/Libraries/Utilities/codegenNativ // shadow tree boundary and drops all interaction events. newArchEnabled=true exposes this. // eslint-disable-next-line @typescript-eslint/no-var-requires const NativeComponentRegistry = require('react-native/Libraries/NativeComponent/NativeComponentRegistry') as { - get: (name: string, viewConfigProvider: () => object) => ComponentType; + get: (name: string, viewConfigProvider: () => ViewConfig) => ComponentType; }; -const pointsDiffer = require('react-native/Libraries/Utilities/differ/pointsDiffer').default as ( - a: object | null, - b: object | null -) => boolean; - interface Props extends Omit { exitFocusNativeId?: string; } diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index aea43ebad46..ba178ddfa0a 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -678,9 +678,10 @@ export class RoomView extends Component { this.consumeJumpParam(messageId); } } else { + const hasLastOpen = 'lastOpen' in room && !!room.lastOpen; await RoomServices.getMessages({ rid: room.rid, - ...('lastOpen' in room && room.lastOpen ? {} : { t: room.t as RoomType }) + t: hasLastOpen ? undefined : (room.t as RoomType) }); // if room is joined diff --git a/app/views/RoomView/services/resolveJumpAnchor.ts b/app/views/RoomView/services/resolveJumpAnchor.ts index d69bc645992..d2663b2e643 100644 --- a/app/views/RoomView/services/resolveJumpAnchor.ts +++ b/app/views/RoomView/services/resolveJumpAnchor.ts @@ -10,7 +10,7 @@ export interface IJumpTarget { } export interface IJumpAnchorDeps { - loadSurroundingMessages: (params: { messageId: string; rid: string }) => Promise; + loadSurroundingMessages: (params: { messageId: string; rid: string }) => Promise; getLocalAnchorTs: (rid: string, ts: Date | number | string) => Promise; } @@ -31,7 +31,7 @@ export const resolveJumpAnchor = async ( } if (target.fromServer) { - const chunk = (await deps.loadSurroundingMessages({ messageId: target.id, rid })) as IMessage[]; + const chunk = await deps.loadSurroundingMessages({ messageId: target.id, rid }); const anchorMessages: AnchorMessage[] = (Array.isArray(chunk) ? chunk : []).map(m => ({ id: m._id, t: m.t, diff --git a/app/views/SearchMessagesView/index.tsx b/app/views/SearchMessagesView/index.tsx index c642a40e78e..0cecb434b31 100644 --- a/app/views/SearchMessagesView/index.tsx +++ b/app/views/SearchMessagesView/index.tsx @@ -212,14 +212,7 @@ class SearchMessagesView extends Component { const { isMasterDetail } = this.props; - let params: { - rid: string; - jumpToMessageId: string; - t: SubscriptionType; - room: TSubscriptionModel | undefined; - tmid?: string; - name?: string; - } = { + const params = { rid: this.rid, jumpToMessageId: item._id, t: this.t, @@ -227,13 +220,12 @@ class SearchMessagesView extends Component { +interface IClearAfterState { + value: ClearAfterValue; + customDate: Date | null; +} + +export const getInitialClearAfterState = (statusExpiresAt: string | undefined): IClearAfterState => { if (!statusExpiresAt) return { value: '', customDate: null }; const expiresAt = dayjs(statusExpiresAt); diff --git a/app/views/StatusView/index.tsx b/app/views/StatusView/index.tsx index 29ab6349715..a5139ebc3a1 100644 --- a/app/views/StatusView/index.tsx +++ b/app/views/StatusView/index.tsx @@ -28,7 +28,7 @@ import { USER_STATUS_TEXT_MAX_LENGTH } from '../../lib/constants/maxLength'; import { type ClearAfterValue, computeExpiresAt, getInitialClearAfterState } from './ClearAfterPicker'; import FooterComponent from './FooterComponent'; -const validationSchema = yup.object().shape({ +const validationSchema = yup.object({ statusText: yup .string() .max(USER_STATUS_TEXT_MAX_LENGTH, I18n.t('Status_text_limit_exceeded', { limit: USER_STATUS_TEXT_MAX_LENGTH })) diff --git a/package.json b/package.json index ce176ad8ba8..097573424c2 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "@bugsnag/cli": "^3.2.1", "@bugsnag/source-maps": "^2.3.3", "@gorhom/bottom-sheet": "^5", + "@oxlint/plugins": "1.80.0", "@react-native-community/cli": "20.0.0", "@react-native-community/cli-platform-android": "20.0.0", "@react-native-community/cli-platform-ios": "20.0.0", @@ -201,7 +202,7 @@ "jest-cli": "^29.7.0", "jest-expo": "~54.0.16", "oxfmt": "^0.60.0", - "oxlint": "^1.75.0", + "oxlint": "^1.80.0", "patch-package": "~8.0.1", "react-dom": "19.1.0", "react-native-dotenv": "3.4.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d411acadbb..d379dab0b29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -388,6 +388,9 @@ importers: '@gorhom/bottom-sheet': specifier: ^5 version: 5.2.8(@types/react@19.1.17)(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.3(@babel/core@7.25.9)(react-native-worklets@0.6.1(@babel/core@7.25.9)(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@oxlint/plugins': + specifier: 1.80.0 + version: 1.80.0 '@react-native-community/cli': specifier: 20.0.0 version: 20.0.0(typescript@7.0.2) @@ -491,8 +494,8 @@ importers: specifier: ^0.60.0 version: 0.60.0 oxlint: - specifier: ^1.75.0 - version: 1.75.0 + specifier: ^1.80.0 + version: 1.80.0 patch-package: specifier: ~8.0.1 version: 8.0.1 @@ -2169,128 +2172,132 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.75.0': - resolution: {integrity: sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==} + '@oxlint/binding-android-arm-eabi@1.80.0': + resolution: {integrity: sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.75.0': - resolution: {integrity: sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==} + '@oxlint/binding-android-arm64@1.80.0': + resolution: {integrity: sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.75.0': - resolution: {integrity: sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==} + '@oxlint/binding-darwin-arm64@1.80.0': + resolution: {integrity: sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.75.0': - resolution: {integrity: sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==} + '@oxlint/binding-darwin-x64@1.80.0': + resolution: {integrity: sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.75.0': - resolution: {integrity: sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==} + '@oxlint/binding-freebsd-x64@1.80.0': + resolution: {integrity: sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.75.0': - resolution: {integrity: sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==} + '@oxlint/binding-linux-arm-gnueabihf@1.80.0': + resolution: {integrity: sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.75.0': - resolution: {integrity: sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==} + '@oxlint/binding-linux-arm-musleabihf@1.80.0': + resolution: {integrity: sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.75.0': - resolution: {integrity: sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==} + '@oxlint/binding-linux-arm64-gnu@1.80.0': + resolution: {integrity: sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.75.0': - resolution: {integrity: sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==} + '@oxlint/binding-linux-arm64-musl@1.80.0': + resolution: {integrity: sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.75.0': - resolution: {integrity: sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==} + '@oxlint/binding-linux-ppc64-gnu@1.80.0': + resolution: {integrity: sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.75.0': - resolution: {integrity: sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==} + '@oxlint/binding-linux-riscv64-gnu@1.80.0': + resolution: {integrity: sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.75.0': - resolution: {integrity: sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==} + '@oxlint/binding-linux-riscv64-musl@1.80.0': + resolution: {integrity: sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.75.0': - resolution: {integrity: sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==} + '@oxlint/binding-linux-s390x-gnu@1.80.0': + resolution: {integrity: sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.75.0': - resolution: {integrity: sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==} + '@oxlint/binding-linux-x64-gnu@1.80.0': + resolution: {integrity: sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.75.0': - resolution: {integrity: sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==} + '@oxlint/binding-linux-x64-musl@1.80.0': + resolution: {integrity: sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.75.0': - resolution: {integrity: sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==} + '@oxlint/binding-openharmony-arm64@1.80.0': + resolution: {integrity: sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.75.0': - resolution: {integrity: sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==} + '@oxlint/binding-win32-arm64-msvc@1.80.0': + resolution: {integrity: sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.75.0': - resolution: {integrity: sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==} + '@oxlint/binding-win32-ia32-msvc@1.80.0': + resolution: {integrity: sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.75.0': - resolution: {integrity: sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==} + '@oxlint/binding-win32-x64-msvc@1.80.0': + resolution: {integrity: sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxlint/plugins@1.80.0': + resolution: {integrity: sha512-QRgH1XqQEYNHa4f1vvPQ5fAdNdncHGIUG1ZWLlGIZHky3qwCEeAKYitZNbZMtaXtAQAAFFTOwqUfzESvimqZNA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -6076,8 +6083,8 @@ packages: vite-plus: optional: true - oxlint@1.75.0: - resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==} + oxlint@1.80.0: + resolution: {integrity: sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -9966,63 +9973,65 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.60.0': optional: true - '@oxlint/binding-android-arm-eabi@1.75.0': + '@oxlint/binding-android-arm-eabi@1.80.0': optional: true - '@oxlint/binding-android-arm64@1.75.0': + '@oxlint/binding-android-arm64@1.80.0': optional: true - '@oxlint/binding-darwin-arm64@1.75.0': + '@oxlint/binding-darwin-arm64@1.80.0': optional: true - '@oxlint/binding-darwin-x64@1.75.0': + '@oxlint/binding-darwin-x64@1.80.0': optional: true - '@oxlint/binding-freebsd-x64@1.75.0': + '@oxlint/binding-freebsd-x64@1.80.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.75.0': + '@oxlint/binding-linux-arm-gnueabihf@1.80.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.75.0': + '@oxlint/binding-linux-arm-musleabihf@1.80.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.75.0': + '@oxlint/binding-linux-arm64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.75.0': + '@oxlint/binding-linux-arm64-musl@1.80.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.75.0': + '@oxlint/binding-linux-ppc64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.75.0': + '@oxlint/binding-linux-riscv64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.75.0': + '@oxlint/binding-linux-riscv64-musl@1.80.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.75.0': + '@oxlint/binding-linux-s390x-gnu@1.80.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.75.0': + '@oxlint/binding-linux-x64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-x64-musl@1.75.0': + '@oxlint/binding-linux-x64-musl@1.80.0': optional: true - '@oxlint/binding-openharmony-arm64@1.75.0': + '@oxlint/binding-openharmony-arm64@1.80.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.75.0': + '@oxlint/binding-win32-arm64-msvc@1.80.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.75.0': + '@oxlint/binding-win32-ia32-msvc@1.80.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.75.0': + '@oxlint/binding-win32-x64-msvc@1.80.0': optional: true + '@oxlint/plugins@1.80.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -14536,27 +14545,27 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.60.0 '@oxfmt/binding-win32-x64-msvc': 0.60.0 - oxlint@1.75.0: + oxlint@1.80.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.75.0 - '@oxlint/binding-android-arm64': 1.75.0 - '@oxlint/binding-darwin-arm64': 1.75.0 - '@oxlint/binding-darwin-x64': 1.75.0 - '@oxlint/binding-freebsd-x64': 1.75.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.75.0 - '@oxlint/binding-linux-arm-musleabihf': 1.75.0 - '@oxlint/binding-linux-arm64-gnu': 1.75.0 - '@oxlint/binding-linux-arm64-musl': 1.75.0 - '@oxlint/binding-linux-ppc64-gnu': 1.75.0 - '@oxlint/binding-linux-riscv64-gnu': 1.75.0 - '@oxlint/binding-linux-riscv64-musl': 1.75.0 - '@oxlint/binding-linux-s390x-gnu': 1.75.0 - '@oxlint/binding-linux-x64-gnu': 1.75.0 - '@oxlint/binding-linux-x64-musl': 1.75.0 - '@oxlint/binding-openharmony-arm64': 1.75.0 - '@oxlint/binding-win32-arm64-msvc': 1.75.0 - '@oxlint/binding-win32-ia32-msvc': 1.75.0 - '@oxlint/binding-win32-x64-msvc': 1.75.0 + '@oxlint/binding-android-arm-eabi': 1.80.0 + '@oxlint/binding-android-arm64': 1.80.0 + '@oxlint/binding-darwin-arm64': 1.80.0 + '@oxlint/binding-darwin-x64': 1.80.0 + '@oxlint/binding-freebsd-x64': 1.80.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.80.0 + '@oxlint/binding-linux-arm-musleabihf': 1.80.0 + '@oxlint/binding-linux-arm64-gnu': 1.80.0 + '@oxlint/binding-linux-arm64-musl': 1.80.0 + '@oxlint/binding-linux-ppc64-gnu': 1.80.0 + '@oxlint/binding-linux-riscv64-gnu': 1.80.0 + '@oxlint/binding-linux-riscv64-musl': 1.80.0 + '@oxlint/binding-linux-s390x-gnu': 1.80.0 + '@oxlint/binding-linux-x64-gnu': 1.80.0 + '@oxlint/binding-linux-x64-musl': 1.80.0 + '@oxlint/binding-openharmony-arm64': 1.80.0 + '@oxlint/binding-win32-arm64-msvc': 1.80.0 + '@oxlint/binding-win32-ia32-msvc': 1.80.0 + '@oxlint/binding-win32-x64-msvc': 1.80.0 p-defer@1.0.0: {} diff --git a/tools/oxlint/anti-slop/README.md b/tools/oxlint/anti-slop/README.md new file mode 100644 index 00000000000..274bfc85f92 --- /dev/null +++ b/tools/oxlint/anti-slop/README.md @@ -0,0 +1,23 @@ +# anti-slop + +Vendored Oxlint plugin. Upstream: https://github.com/dmmulroy/anti-slop + +## Local deviations + +- `no-module-mocking`'s doc comment is reworded — its upstream wording uses a term this repo bans. +- Upstream's `effect/` rules are not vendored — this app has no direct `effect` dependency. +- `no-unknown-parameters`, `no-unknown-returns` and `no-runtime-typeof` were removed. `unknown` + parameters and `typeof` narrowing are used deliberately across our repos. +- `no-module-mocking`'s computed-access branch derives its method names from the shared + `moduleMockMethods` list instead of repeating them inline, so both branches stay in sync. +- `no-chained-type-assertions` and `no-unsafe-dictionary-type` run as warnings. Their remaining + findings need real parsing at the server-payload boundary, not a lint fix. +- `no-module-mocking` and `require-safety-comment-for-type-assertion` also run as warnings. Both + fire mostly in tests, where module mocking and asserted fixtures are the intended style. + +## Constraints + +This directory is not app source. It uses `.ts` import specifiers, so it is excluded from +`tsconfig.json` and from `.oxfmtrc.json`. Do not lint, format or typecheck it as app code. + +`@oxlint/plugins` and `oxlint` versions must stay matched. diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts new file mode 100644 index 00000000000..e29d89e0342 --- /dev/null +++ b/tools/oxlint/anti-slop/index.ts @@ -0,0 +1,35 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; + +import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts"; +import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts"; +import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts"; +import { noModuleMockingRule } from "./rules/no-module-mocking.ts"; +import { noObjectParametersRule } from "./rules/no-object-parameters.ts"; +import { noReflectApplyRule } from "./rules/no-reflect-apply.ts"; +import { noReflectGetRule } from "./rules/no-reflect-get.ts"; +import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts"; +import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts"; +import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts"; +import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts"; +import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts"; + +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = eslintCompatPlugin({ + meta: { name: "anti-slop" }, + rules: { + "no-chained-type-assertions": noChainedTypeAssertionsRule, + "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-module-mocking": noModuleMockingRule, + "no-object-parameters": noObjectParametersRule, + "no-reflect-apply": noReflectApplyRule, + "no-reflect-get": noReflectGetRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, + "no-unknown-type-aliases": noUnknownTypeAliasesRule, + "no-widen-then-assert": noWidenThenAssertRule, + "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule, + }, +}); + +export default antiSlopPlugin; diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 00000000000..0d118527804 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const" + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", + }, + messages: { + chained: + "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 00000000000..ae7248d36e5 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,49 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === "ObjectExpression" && node.properties.length === 0; +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node); + return ( + conditional.type === "ConditionalExpression" && + (isEmptyObjectExpression(conditional.consequent) || + isEmptyObjectExpression(conditional.alternate)) + ); +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow object spreads that conditionally spread an empty object to omit fields.", + }, + messages: { + avoid: + "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 00000000000..2a6806c6994 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,247 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from "../shared/dictionary-types.ts"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function; + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current; +} + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" + ? definition.node + : null; +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === "VariableDeclaration" && + declarator.parent.kind === "const" && + variable.references.every((reference) => reference.init || !reference.isWrite()) + ); +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set(), +): boolean { + if (isKnownEvidenceExpression(expression)) return true; + const unwrapped = unwrapExpression(expression); + if (unwrapped.type !== "Identifier") return false; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || visitedVariables.has(variable)) return false; + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables); +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment, +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment); +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if ( + current.type === "ArrowFunctionExpression" || + current.type === "FunctionDeclaration" || + current.type === "FunctionExpression" + ) { + return current; + } + current = current.parent; + } + return null; +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name; + if (key.type === "Literal") return String(key.value); + return sourceCode.getText(key); +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return "anonymous function"; + if (owner.id !== null) return owner.id.name; + const parent = owner.parent; + if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") + return parent.id.name; + if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key); + return "anonymous function"; +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression); + return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0; +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === "open dictionary" || destination.kind === "generic container"; +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion"; +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.", + }, + messages: { + widening: + "The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + + const reportFlow = ( + expression: ESTree.Expression, + destination: WideningTarget | null, + subject: string, + ) => { + if (destination === null) return; + if ( + isDictionaryAccumulatorTarget(destination) && + isEmptyObjectExpression(expression) + ) { + return; + } + if (!hasKnownEvidence(context.sourceCode, expression)) return; + context.report({ + node: expression, + messageId: "widening", + data: { subject, target: destination.kind }, + }); + }; + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment); + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== "Identifier") return; + reportFlow( + node.init, + targetFromAnnotation(node.id.typeAnnotation), + `binding \`${node.id.name}\``, + ); + }, + PropertyDefinition(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AccessorProperty(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AssignmentExpression(node) { + if (node.operator !== "=" || node.left.type !== "Identifier") return; + const variable = resolveVariable(context.sourceCode, node.left); + if (variable === null) return; + const declarator = variableDeclarator(variable); + if (declarator === null || declarator.id.type !== "Identifier") return; + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\``, + ); + }, + ReturnStatement(node) { + if (node.argument === null) return; + const owner = enclosingFunction(node); + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\``, + ); + }, + ArrowFunctionExpression(node) { + if (node.body.type === "BlockStatement") return; + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\``, + ); + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts new file mode 100644 index 00000000000..308fed2618b --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.ts @@ -0,0 +1,90 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]); + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function importedName(node: ESTree.Node): string | null { + if (node.type !== "ImportSpecifier") return null; + return node.imported.type === "Identifier" ? node.imported.name : node.imported.value; +} + +function isTestFrameworkObject( + sourceCode: SourceCode, + expression: ESTree.Expression, +): expression is ESTree.IdentifierReference { + if (expression.type !== "Identifier") return false; + if ( + (expression.name === "vi" || expression.name === "jest") && + sourceCode.isGlobalReference(expression) + ) { + return true; + } + + const variable = resolveVariable(sourceCode, expression); + if (variable === null || variable.defs.length === 0) { + return expression.name === "vi" || expression.name === "jest"; + } + return variable.defs.some((definition) => { + if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") { + return false; + } + const source = definition.parent.source.value; + const name = importedName(definition.node); + return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest"); + }); +} + +function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isTestFrameworkObject(sourceCode, callee.object)) return false; + const property = callee.property; + const method = callee.computed + ? property.type === "Literal" && + typeof property.value === "string" && + moduleMockMethods.has(property.value) + ? property.value + : null + : property.type === "Identifier" + ? property.name + : null; + return method !== null && moduleMockMethods.has(method); +} + +/** Ban test framework module mocking in favor of real injected dependencies. */ +export const noModuleMockingRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.", + }, + messages: { + moduleMock: + "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (moduleMockCall(context.sourceCode, node.callee)) { + context.report({ node, messageId: "moduleMock" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 00000000000..29b990f3395 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,126 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceCode: SourceCode): string { + return parameter.type === "Identifier" + ? parameter.name + : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, ""); +} + +/** Ban the broad object type on function inputs, including local aliases to object. */ +export const noObjectParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", + }, + messages: { + objectParameter: + "Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.", + }, + }, + createOnce(context) { + const aliases = new Map(); + + const resolvesToObject = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set(), + ): boolean => { + if (type.type === "TSObjectKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToObject(type.typeAnnotation, shadowedAliases, visited); + if (type.type === "TSUnionType") { + return type.types.some((member) => + resolvesToObject(member, shadowedAliases, visited), + ); + } + if ( + type.type !== "TSTypeReference" || + type.typeName.type !== "Identifier" || + (type.typeArguments !== null && + type.typeArguments !== undefined && + type.typeArguments.params.length > 0) || + visited.has(type.typeName.name) || + shadowedAliases.has(type.typeName.name) + ) { + return false; + } + const alias = aliases.get(type.typeName.name); + if (alias === undefined) return false; + const nextVisited = new Set(visited); + nextVisited.add(type.typeName.name); + return resolvesToObject(alias, shadowedAliases, nextVisited); + }; + + const checkParameters = (node: ParameterOwner) => { + const shadowedAliases = lexicalTypeParameterNames( + node, + context.sourceCode.visitorKeys, + ); + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: parameterName(parameter, context.sourceCode) }, + }); + } + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if ( + declaration?.type === "TSTypeAliasDeclaration" && + (declaration.typeParameters === null || declaration.typeParameters === undefined) + ) { + aliases.set(declaration.id.name, declaration.typeAnnotation); + } + } + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts new file mode 100644 index 00000000000..2cc30451bda --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ +export const noReflectApplyRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.", + }, + messages: { + reflectApply: + "Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) { + context.report({ node, messageId: "reflectApply" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.ts new file mode 100644 index 00000000000..cf630ecc005 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-get.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.", + }, + messages: { + reflectGet: + "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) { + context.report({ node, messageId: "reflectGet" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 00000000000..afc00dd4124 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,39 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +const FORBIDDEN_SYMBOL_NAME = "shape"; + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME); +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', + }, + }, + createOnce(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name)) return; + context.report({ + node, + messageId: "forbiddenSymbolName", + data: { name: node.name }, + }); + }; + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 00000000000..3e328fdfc91 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,70 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); + if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; + return type.typeArguments === null || + type.typeArguments === undefined || + type.typeArguments.params.length === 0 + ? type.typeName.name + : null; +} + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.", + }, + messages: { + unknownAlias: + "Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.", + }, + }, + createOnce(context) { + const aliases = new Map(); + + const resolvesToUnknown = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToUnknown(type.typeAnnotation, visited); + const name = referencedAliasName(type); + if (name === null || visited.has(name)) return false; + const alias = aliases.get(name); + if ( + alias === undefined || + (alias.typeParameters !== null && alias.typeParameters !== undefined) + ) { + return false; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToUnknown(alias.typeAnnotation, nextVisited); + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type === "TSTypeAliasDeclaration") { + aliases.set(declaration.id.name, declaration); + } + } + for (const alias of aliases.values()) { + if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue; + context.report({ + node: alias.id, + messageId: "unknownAlias", + data: { alias: alias.id.name }, + }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 00000000000..8c45eed2762 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,134 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.ts"; + +import type { ESTree } from "@oxlint/plugins"; + +const typeNodeKinds: ReadonlySet = new Set([ + "JSDocNonNullableType", + "JSDocNullableType", + "JSDocUnknownType", + "TSAnyKeyword", + "TSArrayType", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSConditionalType", + "TSConstructorType", + "TSFunctionType", + "TSImportType", + "TSIndexedAccessType", + "TSInferType", + "TSIntersectionType", + "TSIntrinsicKeyword", + "TSLiteralType", + "TSMappedType", + "TSNamedTupleMember", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSParenthesizedType", + "TSStringKeyword", + "TSSymbolKeyword", + "TSTemplateLiteralType", + "TSThisType", + "TSTupleType", + "TSTypeLiteral", + "TSTypeOperator", + "TSTypePredicate", + "TSTypeQuery", + "TSTypeReference", + "TSUndefinedKeyword", + "TSUnionType", + "TSUnknownKeyword", + "TSVoidKeyword", +]); + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return typeNodeKinds.has(node.type); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (current.type === "TSTypeAliasDeclaration") return true; + current = current.parent; + } + return false; +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false; + const name = typeReferenceName(node); + return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node); +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isPlainAliasConsumerUse(node, environment)) return false; + if (classifyUnsafeDictionary(node, environment) === null) return false; + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) + return false; + current = current.parent; + } + return true; +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.", + }, + messages: { + unsafeDictionary: + "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: "unsafeDictionary", data: { value } }); + }; + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return; + const unsafe = classifyUnsafeDictionary(node, environment); + if (unsafe === null) return; + report(node, unsafe.unsafeValue); + }; + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if ( + environment === null || + node.typeAnnotation === null || + node.parent.type === "TSTypeLiteral" + ) + return; + const unsafe = classifyUnsafeDictionaryValue( + node.typeAnnotation.typeAnnotation, + environment, + ); + if (unsafe !== null) report(node, unsafe.unsafeValue); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 00000000000..c5e07f7fc55 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,366 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, Variable } from "@oxlint/plugins"; + +type BroadTypeKind = "top" | "object" | "record"; + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null; +}; + +const functionBoundaryTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") current = current.expression; + return current; +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type; + while (current.type === "TSParenthesizedType") current = current.typeAnnotation; + return current; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + + if (unwrapped.type === "TSTypeReference") { + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); + } + + if (typeReferenceName(unwrapped) !== "Record") return false; + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ); + } + + if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; + const [member] = unwrapped.members; + const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; + return ( + member?.type === "TSIndexSignature" && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ); +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + return isBroadRecordType(unwrapped) ? "record" : null; +} + +function assertedExpression( + node: ESTree.TSAsExpression | ESTree.TSTypeAssertion, +): ESTree.Expression { + return unwrapExpressionParentheses(node.expression); +} + +function assertionFromExpression( + expression: ESTree.Expression, +): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + ? unwrapped + : null; +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); +} + +function typesHaveSameSyntax( + sourceText: string, + left: ESTree.TSType | null, + right: ESTree.TSType, +): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ); +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + switch (unwrapped.type) { + case "TSArrayType": + case "TSConstructorType": + case "TSFunctionType": + case "TSMappedType": + case "TSObjectKeyword": + case "TSTupleType": + return true; + case "TSTypeLiteral": + return unwrapped.members.length > 0; + case "TSIntersectionType": + return unwrapped.types.every(isDefinitelyObjectType); + case "TSTypeOperator": + return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); + default: + return false; + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); + } + + if (unwrapped.type !== "TSTypeReference") return false; + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isDefinitelyNarrowerRecordType(inner); + } + if (typeReferenceName(unwrapped) !== "Record") return false; + + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) + ); +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (functionBoundaryTypes.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node; + readonly resolved: Variable | null; + }[]; + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && + candidate.identifier.end === identifier.end, + ); + if (reference !== undefined) return reference.resolved; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + return definition.node; + } + } + return null; +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression); + + if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; + return { type: unwrapped.typeAnnotation }; + } + + if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { + return { type: null }; + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "NewExpression" || + unwrapped.type === "ObjectExpression" + ) { + return { type: null }; + } + + if (unwrapped.type !== "Identifier") return null; + const variable = resolvedVariableForIdentifier(scopes, unwrapped); + if (variable === null || visitedVariables.has(variable)) return null; + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ); + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; + } + + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null; + } + + return knownValueEvidence( + declarator.init, + scopes, + boundary, + new Set([...visitedVariables, variable]), + ); +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind; + readonly evidence: KnownValueEvidence; + readonly declaredAt: number; + readonly boundary: ESTree.Node | null; +} | null { + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.id.type !== "Identifier" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null; + } + + const boundary = functionBoundary(declarator); + const declaredType = declarator.id.typeAnnotation?.typeAnnotation; + const initializerAssertion = assertionFromExpression(declarator.init); + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const broadKind = declaredBroadKind ?? initializerBroadKind; + if (broadKind === null) return null; + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init; + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false; + if (broadKind === "top") return true; + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; + if (broadKind === "object") return isDefinitelyObjectType(assertedType); + return isDefinitelyNarrowerRecordType(assertedType); +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', + }, + }, + createOnce(context) { + let scopes: Parameters[0] = []; + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node); + if (expression.type !== "Identifier") return; + + const variable = resolvedVariableForIdentifier(scopes, expression); + if (variable === null) return; + const widened = widenedBinding(variable, scopes); + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower( + context.sourceCode.text, + widened.broadKind, + widened.evidence, + node.typeAnnotation, + ) + ) { + return; + } + + context.report({ + node, + messageId: "widenThenAssert", + data: { name: expression.name }, + }); + }; + + return { + Program() { + scopes = context.sourceCode.scopeManager.scopes; + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 00000000000..f1a2ffcf945 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,62 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +const commentOwnerKinds = new Set([ + "ExpressionStatement", + "PropertyDefinition", + "ReturnStatement", + "ThrowStatement", + "VariableDeclaration", +]); + +function isConstAssertion(node: TypeAssertion): boolean { + return ( + node.typeAnnotation.type === "TSTypeReference" && + node.typeAnnotation.typeName.type === "Identifier" && + node.typeAnnotation.typeName.name === "const" + ); +} + +function hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): boolean { + let current: ESTree.Node = node; + while (true) { + if ( + sourceCode + .getCommentsBefore(current) + .some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value)) + ) { + return true; + } + if (commentOwnerKinds.has(current.type) || current.parent.type === "Program") return false; + current = current.parent; + } +} + +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.", + }, + messages: { + missingSafetyComment: + "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", + }, + }, + createOnce(context) { + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return; + context.report({ node, messageId: "missingSafetyComment" }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts new file mode 100644 index 00000000000..865170047f3 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,502 @@ +import type { ESTree } from "@oxlint/plugins"; + +const BUILT_INS = new Set([ + "Record", + "Readonly", + "Partial", + "Required", + "Pick", + "Omit", + "PropertyKey", + "NonNullable", +]); +const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); + +type TypeAliasEnvironment = ReadonlyMap; + +type ResolvedType = { + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; +}; + +export type UnsafeDictionary = { + readonly kind: "unsafe-dictionary"; + readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; +}; + +export type WideningTargetKind = + | "anonymous object" + | "generic container" + | "object" + | "open dictionary" + | "unknown"; + +export type WideningTarget = { + readonly kind: WideningTargetKind; +}; + +export type TypeEnvironment = { + readonly aliases: ReadonlyMap; + readonly interfaces: ReadonlyMap; + readonly shadowedBuiltIns: ReadonlySet; +}; + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? (statement.declaration ?? null) + : statement; +} + +export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment { + const aliases = new Map(); + const interfaces = new Map(); + const shadowedBuiltIns = new Set(); + + for (const statement of program.body) { + const declaration = declaredStatement(statement); + if (declaration?.type === "ImportDeclaration") { + for (const specifier of declaration.specifiers) { + if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name); + } + continue; + } + + if (declaration?.type === "TSTypeAliasDeclaration") { + const existing = aliases.get(declaration.id.name); + if (existing === undefined) aliases.set(declaration.id.name, declaration); + else shadowedBuiltIns.add(declaration.id.name); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSInterfaceDeclaration") { + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSEnumDeclaration") { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if ( + (declaration?.type === "ClassDeclaration" || + declaration?.type === "FunctionDeclaration") && + declaration.id !== null + ) { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + } + } + + return { aliases, interfaces, shadowedBuiltIns }; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isBuiltIn(name: string, environment: TypeEnvironment): boolean { + return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name); +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === "TSTypeReference" && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type; + while ( + current.type === "TSParenthesizedType" || + (current.type === "TSTypeOperator" && current.operator === "readonly") + ) { + current = current.typeAnnotation; + } + return current; +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === "TSNeverKeyword"; +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === "TSPropertySignature" && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} + +function isEffectivelyEmptyInterface( + declarations: readonly ESTree.TSInterfaceDeclaration[], +): boolean { + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== "TSTypeReference") return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary["unsafeValue"] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return "unknown"; + if (unwrapped.type === "TSAnyKeyword") return "any"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) + return "empty-object"; + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? "union" + : null; + } + if (unwrapped.type === "TSIntersectionType") { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ); + if (unsafeMembers.includes("any")) return "any"; + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) + ? unsafeMembers[0] + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === "TSIndexSignature" && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ); + } + + if (unwrapped.type === "TSMappedType") { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== "TSTypeReference") return []; + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === "Record" && isBuiltIn(name, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) { + const source = unwrapped.typeArguments?.params[0]; + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); + } + + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return []; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return []; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; +} + +export function classifyUnsafeDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set(), + ); + if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; + } + return null; +} + +function resolvesToDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): boolean { + return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0; +} + +export function classifyWideningTarget( + type: ESTree.TSType, + environment: TypeEnvironment, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : unwrapped.members.length > 0 + ? { kind: "anonymous object" } + : null; + } + if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" }; + const alias = environment.aliases.get(name); + if (alias === undefined) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + return substitutions !== null && + resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) + ? { kind: "generic container" } + : null; + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved; +} + +function isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, +): boolean { + const unwrapped = unwrapTransparentType(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.every((member) => + isBroadMappedKey(member, environment, substitutions), + ); + } + if (unwrapped.type !== "TSTypeReference") return false; + const name = typeReferenceName(unwrapped); + if (name === null) return false; + const substitution = substitutions.get(name); + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions); + } + return name === "PropertyKey" && isBuiltIn(name, environment); +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type === "TSMappedType") { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget( + substitution, + environment, + substitutions, + resolvingAliases, + ); + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); + } + if (name === "Record" && isBuiltIn(name, environment)) { + return { kind: "open dictionary" }; + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + nextSubstitutions, + nextResolving, + ); +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current.type === "ObjectExpression" && current.properties.length > 0; +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" + ) { + current = current.expression; + } + if (current.type === "ObjectExpression") return true; + return ( + current.type === "ArrayExpression" || + current.type === "ArrowFunctionExpression" || + current.type === "ClassExpression" || + current.type === "FunctionExpression" || + current.type === "NewExpression" || + current.type === "Literal" || + current.type === "TemplateLiteral" || + current.type === "UnaryExpression" + ); +} diff --git a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts new file mode 100644 index 00000000000..7cdb18c911c --- /dev/null +++ b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -0,0 +1,61 @@ +import type { ESTree } from "@oxlint/plugins"; + +type VisitorKeys = Readonly>; + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function collectInferTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set, +): void { + if (node.type === "TSInferType") names.add(node.typeParameter.name.name); + const record = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} + +/** Collect type binders that are in scope at a node and can shadow module aliases. */ +export function lexicalTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, +): ReadonlySet { + const names = new Set(); + let descendant: ESTree.Node = node; + let current: ESTree.Node | null = node; + while (current !== null && current.type !== "Program") { + if ("typeParameters" in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if ( + current.type === "TSMappedType" && + (descendant === current.nameType || descendant === current.typeAnnotation) + ) { + names.add(current.key.name); + } + if (current.type === "TSConditionalType" && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names); + } + descendant = current; + current = current.parent; + } + return names; +} diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts new file mode 100644 index 00000000000..39bc218c3bb --- /dev/null +++ b/tools/oxlint/anti-slop/shared/reflect-method.ts @@ -0,0 +1,35 @@ +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean { + if (expression.type !== "Identifier" || expression.name !== "Reflect") return false; + if (sourceCode.isGlobalReference(expression)) return true; + const variable = resolveVariable(sourceCode, expression); + return variable === null || variable.defs.length === 0; +} + +/** Reports whether a call target names one method on the global Reflect object. */ +export function isGlobalReflectMethodCall( + sourceCode: SourceCode, + callee: ESTree.Expression, + methodName: string, +): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isGlobalReflect(sourceCode, callee.object)) return false; + const property = callee.property; + return callee.computed + ? property.type === "Literal" && property.value === methodName + : property.type === "Identifier" && property.name === methodName; +} diff --git a/tsconfig.json b/tsconfig.json index 136cf41a3e1..9ee2a728602 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, - "exclude": ["node_modules", "__mocks__", "**/.worktrees/**"] + "exclude": ["node_modules", "__mocks__", "**/.worktrees/**", "tools/oxlint/anti-slop"] }