From fca10221b917a2e54cd4af247d470cc7086b22c7 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 2 May 2026 12:14:23 -0400 Subject: [PATCH] chore: promote no-explicit-any from warn to error (#12244) * chore: promote no-explicit-any from warn to error and resolve violations Upgrades the oxlint rule severity and removes all 40 existing `no-explicit-any` warnings across the codebase. Most call sites gained proper types (SharedEditor refs, JSONNode/JSONMark for ProseMirror JSON walking, DocumentsStore, dd-trace `Span` parameter inference, prosemirror Fragment public API in place of internal `(fragment as any).content`). A few load-bearing `any` uses were preserved with scoped disable comments where changing the type would cascade widely (Sequelize JSONB columns on `Event`, the `withTracing` higher-order function generic, `Extension.options` consumed by many subclasses, dd-trace's `req` patching). Co-Authored-By: Claude Opus 4.7 --- .oxlintrc.json | 2 +- app/editor/components/ComponentView.tsx | 6 ++-- app/editor/components/SuggestionsMenu.tsx | 6 ++-- app/editor/extensions/SelectionToolbar.tsx | 11 +++--- app/editor/extensions/UpArrowAtStart.ts | 6 ++-- app/editor/index.tsx | 19 +++++++---- app/scenes/Collection/index.tsx | 9 ++++- app/scenes/Document/components/Editor.tsx | 18 +++++----- .../components/History/PaginatedEventList.tsx | 4 +-- .../Document/components/MultiplayerEditor.tsx | 9 +++-- server/editor/index.ts | 34 ++++++------------- server/logging/Logger.ts | 3 +- server/logging/tracer.ts | 6 +++- server/models/Event.ts | 2 ++ server/models/helpers/ProsemirrorHelper.tsx | 8 ++--- server/queues/tasks/EmailTask.ts | 2 +- server/services/websockets.ts | 2 +- server/storage/database.ts | 12 ++++--- server/tools/util.ts | 2 ++ server/utils/getInstallationInfo.ts | 7 ++-- server/validation.ts | 6 +++- shared/editor/lib/Extension.ts | 2 ++ shared/env.ts | 1 + shared/types.ts | 10 +++--- 24 files changed, 107 insertions(+), 80 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 13ad20514d..6be149f106 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -31,7 +31,7 @@ "no-empty-pattern": "error", "no-empty-static-block": "error", "no-ex-assign": "error", - "no-explicit-any": "warn", + "no-explicit-any": "error", "no-extra-boolean-cast": "error", "no-fallthrough": "error", "no-func-assign": "error", diff --git a/app/editor/components/ComponentView.tsx b/app/editor/components/ComponentView.tsx index 4177f547f8..0132ff9071 100644 --- a/app/editor/components/ComponentView.tsx +++ b/app/editor/components/ComponentView.tsx @@ -119,10 +119,12 @@ export default class ComponentView { // Apply classes from inline decorations. this.decorations.forEach((decoration) => { // For inline decorations, attrs contain the class property. - const attrs = (decoration as any).type?.attrs; + const attrs = ( + decoration as Decoration & { type?: { attrs?: { class?: string } } } + ).type?.attrs; if (attrs?.class) { const classes = attrs.class.split(" "); - classes.forEach((className: string) => { + classes.forEach((className) => { if (className && this.dom) { this.dom.classList.add(className); } diff --git a/app/editor/components/SuggestionsMenu.tsx b/app/editor/components/SuggestionsMenu.tsx index 31b8de8014..93f180ef97 100644 --- a/app/editor/components/SuggestionsMenu.tsx +++ b/app/editor/components/SuggestionsMenu.tsx @@ -345,7 +345,7 @@ function SuggestionsMenu(props: Props) { } }; - const triggerFilePick = (accept: string, attrs?: Record) => { + const triggerFilePick = (accept: string, attrs?: Record) => { if (inputRef.current) { if (accept) { inputRef.current.accept = accept; @@ -887,7 +887,7 @@ function SuggestionsMenu(props: Props) { onPointerMove={handlePointerMove} onPointerDown={handlePointerDown} > - {props.renderMenuItem(item as any, index, { + {props.renderMenuItem(item as unknown as T, index, { selected: index === selectedIndex, disclosure: hasChildren, onClick: handleOnClick, @@ -1053,7 +1053,7 @@ function SuggestionsMenu(props: Props) { key={`sub-${childIndex}-${child.name}`} onPointerMove={handleChildPointerMove} > - {props.renderMenuItem(child as any, childIndex, { + {props.renderMenuItem(child as unknown as T, childIndex, { selected: childIndex === submenu.selectedIndex, onClick: handleChildClick, })} diff --git a/app/editor/extensions/SelectionToolbar.tsx b/app/editor/extensions/SelectionToolbar.tsx index 8ac6235885..be4c63e470 100644 --- a/app/editor/extensions/SelectionToolbar.tsx +++ b/app/editor/extensions/SelectionToolbar.tsx @@ -1,4 +1,3 @@ -import some from "lodash/some"; import { action, observable } from "mobx"; import type { EditorState, Selection } from "prosemirror-state"; import { NodeSelection, Plugin, TextSelection } from "prosemirror-state"; @@ -82,12 +81,12 @@ export default class SelectionToolbarExtension extends Extension { return false; } - const slice = selection.content(); - const fragment = slice.content; - const nodes = (fragment as any).content; + const fragment = selection.content().content; - if (some(nodes, (n) => n.content.size)) { - return selection; + for (let i = 0; i < fragment.childCount; i++) { + if (fragment.child(i).content.size) { + return selection; + } } return false; diff --git a/app/editor/extensions/UpArrowAtStart.ts b/app/editor/extensions/UpArrowAtStart.ts index e3a3de343b..34a4201745 100644 --- a/app/editor/extensions/UpArrowAtStart.ts +++ b/app/editor/extensions/UpArrowAtStart.ts @@ -28,9 +28,9 @@ export default class UpArrowAtStart extends Extension { const isAtDocStart = $pos.parentOffset === 0 && $pos.depth <= 1; if (isAtDocStart) { - // Call the onUpArrowAtStart callback if it exists - // Cast to any to access the custom prop since it's not in the base Props type - const props = this.editor.props as any; + const props = this.editor.props as { + onUpArrowAtStart?: () => void; + }; if (props.onUpArrowAtStart) { props.onUpArrowAtStart(); return true; diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 563cac22f9..40d212bf8d 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -38,7 +38,11 @@ import { basicExtensions as extensions } from "@shared/editor/nodes"; import type Node from "@shared/editor/nodes/Node"; import type ReactNode from "@shared/editor/nodes/ReactNode"; import type { ComponentProps } from "@shared/editor/types"; -import type { ProsemirrorData, UserPreferences } from "@shared/types"; +import type { + ProsemirrorData, + ProsemirrorMark, + UserPreferences, +} from "@shared/types"; import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import EventEmitter from "@shared/utils/events"; import type Document from "~/models/Document"; @@ -117,7 +121,8 @@ export type Props = { /** Callback when user uses cancel key combo */ onCancel?: () => void; /** Callback when user changes editor content */ - onChange?: (value: () => any) => void; + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + onChange?: (value: (asString?: boolean, trim?: boolean) => any) => void; /** Callback when a comment mark is clicked */ onClickCommentMark?: (commentId: string) => void; /** @@ -755,9 +760,9 @@ export class Editor extends React.PureComponent< } if (isArray(node.attrs?.marks)) { - const existingMarks = node.attrs.marks; + const existingMarks = node.attrs.marks as ProsemirrorMark[]; const updatedMarks = existingMarks.filter( - (mark: any) => mark.attrs.id !== commentId + (mark) => mark.attrs?.id !== commentId ); const attrs = { ...node.attrs, @@ -800,9 +805,9 @@ export class Editor extends React.PureComponent< } if (isArray(node.attrs?.marks)) { - const existingMarks = node.attrs.marks; - const updatedMarks = existingMarks.map((mark: any) => - mark.type === "comment" && mark.attrs.id === commentId + const existingMarks = node.attrs.marks as ProsemirrorMark[]; + const updatedMarks = existingMarks.map((mark) => + mark.type === "comment" && mark.attrs?.id === commentId ? { ...mark, attrs: { ...mark.attrs, ...attrs } } : mark ); diff --git a/app/scenes/Collection/index.tsx b/app/scenes/Collection/index.tsx index 10d239feab..40646e22b1 100644 --- a/app/scenes/Collection/index.tsx +++ b/app/scenes/Collection/index.tsx @@ -14,6 +14,7 @@ import styled from "styled-components"; import { s } from "@shared/styles"; import { StatusFilter } from "@shared/types"; import type Collection from "~/models/Collection"; +import type DocumentsStore from "~/stores/DocumentsStore"; import CenteredContent from "~/components/CenteredContent"; import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb"; import Heading from "~/components/Heading"; @@ -362,7 +363,13 @@ const Content = styled.div` `; const RecentDocuments = observer( - ({ collection, documents }: { collection: Collection; documents: any }) => { + ({ + collection, + documents, + }: { + collection: Collection; + documents: DocumentsStore; + }) => { useEffect(() => { void collection.fetchDocuments(); }, [collection]); diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 251bc93106..66d9af3f53 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -15,6 +15,7 @@ import type { RefHandle } from "~/components/ContentEditable"; import { useDocumentContext } from "~/components/DocumentContext"; import type { Props as EditorProps } from "~/components/Editor"; import Editor from "~/components/Editor"; +import type { Editor as SharedEditor } from "~/editor"; import Flex from "~/components/Flex"; import Time from "~/components/Time"; import { withUIExtensions } from "~/editor/extensions"; @@ -59,7 +60,8 @@ type Props = Omit & { * The main document editor includes an editable title with metadata below it, * and support for commenting. */ -function DocumentEditor(props: Props, ref: React.RefObject) { +function DocumentEditor(props: Props, ref: React.ForwardedRef) { + const editorRef = React.useRef(null); const titleRef = React.useRef(null); const { t } = useTranslation(); const match = useRouteMatch(); @@ -87,10 +89,10 @@ function DocumentEditor(props: Props, ref: React.RefObject) { const iconColor = document.color ?? (first(colorPalette) as string); const childRef = React.useRef(null); const focusAtStart = React.useCallback(() => { - if (ref.current) { - ref.current.focusAtStart(); + if (editorRef.current) { + editorRef.current.focusAtStart(); } - }, [ref]); + }, []); React.useEffect(() => { if (focusedComment && focusedComment.documentId === document.id) { @@ -113,15 +115,15 @@ function DocumentEditor(props: Props, ref: React.RefObject) { const handleGoToNextInput = React.useCallback( (insertParagraph: boolean) => { - if (insertParagraph && ref.current) { - const { view } = ref.current; + if (insertParagraph && editorRef.current) { + const { view } = editorRef.current; const { dispatch, state } = view; dispatch(state.tr.insert(0, state.schema.nodes.paragraph.create())); } focusAtStart(); }, - [focusAtStart, ref] + [focusAtStart] ); // Create a Comment model in local store when a comment mark is created, this @@ -231,7 +233,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { /> ) : null} ; type Props = { items: Item[]; document: Document; - fetch: (options: Record | undefined) => Promise; - options?: Record; + fetch: (options: Record | undefined) => Promise; + options?: Record; heading?: React.ReactNode; empty?: JSX.Element; }; diff --git a/app/scenes/Document/components/MultiplayerEditor.tsx b/app/scenes/Document/components/MultiplayerEditor.tsx index 4042bafcee..6bdf9c38b6 100644 --- a/app/scenes/Document/components/MultiplayerEditor.tsx +++ b/app/scenes/Document/components/MultiplayerEditor.tsx @@ -7,6 +7,7 @@ import { useEffect, forwardRef, useRef, + type ForwardedRef, } from "react"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; @@ -18,6 +19,7 @@ import EDITOR_VERSION from "@shared/editor/version"; import { supportsPassiveListener } from "@shared/utils/browser"; import type { Props as EditorProps } from "~/components/Editor"; import Editor from "~/components/Editor"; +import type { Editor as SharedEditor } from "~/editor"; import MultiplayerExtension from "~/editor/extensions/Multiplayer"; import env from "~/env"; import useCurrentUser from "~/hooks/useCurrentUser"; @@ -50,7 +52,10 @@ type MessageEvent = { }; }; -function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { +function MultiplayerEditor( + { onSynced, ...props }: Props, + ref: ForwardedRef +) { const documentId = props.id; const history = useHistory(); const { t } = useTranslation(); @@ -352,4 +357,4 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { ); } -export default forwardRef(MultiplayerEditor); +export default forwardRef(MultiplayerEditor); diff --git a/server/editor/index.ts b/server/editor/index.ts index 0807d17247..193762a029 100644 --- a/server/editor/index.ts +++ b/server/editor/index.ts @@ -1,6 +1,7 @@ import data from "@emoji-mart/data"; import type { EmojiMartData } from "@emoji-mart/data"; import { Schema } from "prosemirror-model"; +import type { Editor } from "~/editor"; import ExtensionManager from "@shared/editor/lib/ExtensionManager"; import { populateEmojiData } from "@shared/editor/lib/emoji"; import { @@ -12,6 +13,12 @@ import Mention from "@shared/editor/nodes/Mention"; populateEmojiData(data as EmojiMartData); +// Server-side parsing/serializing only requires schema and a few static props, +// but the Extension API expects a full Editor. This stub satisfies bindEditor +// without instantiating the React component. +const stubEditor = (s: Schema): Editor => + ({ schema: s, props: { theme: { isDark: false } } }) as unknown as Editor; + const extensions = withComments(richExtensions); export const extensionManager = new ExtensionManager(extensions); @@ -21,14 +28,7 @@ export const schema = new Schema({ }); for (const extension of extensionManager.extensions) { - extension.bindEditor({ - schema, - props: { - theme: { - isDark: false, - }, - }, - } as any); + extension.bindEditor(stubEditor(schema)); } export const parser = extensionManager.parser({ @@ -48,14 +48,7 @@ export const basicSchema = new Schema({ }); for (const extension of basicExtensionManager.extensions) { - extension.bindEditor({ - schema: basicSchema, - props: { - theme: { - isDark: false, - }, - }, - } as any); + extension.bindEditor(stubEditor(basicSchema)); } export const basicParser = basicExtensionManager.parser({ @@ -72,14 +65,7 @@ export const commentSchema = new Schema({ }); for (const extension of commentExtensionManager.extensions) { - extension.bindEditor({ - schema: commentSchema, - props: { - theme: { - isDark: false, - }, - }, - } as any); + extension.bindEditor(stubEditor(commentSchema)); } export const commentParser = commentExtensionManager.parser({ diff --git a/server/logging/Logger.ts b/server/logging/Logger.ts index a062242fe4..f1565c7783 100644 --- a/server/logging/Logger.ts +++ b/server/logging/Logger.ts @@ -194,12 +194,11 @@ class Logger { // Errors have non-enumerable message/stack which are dropped by spreads // and JSON serialization, so convert them to a plain object up-front. if (input instanceof Error) { - // oxlint-disable-next-line @typescript-eslint/no-explicit-any return { name: input.name, message: input.message, stack: input.stack, - } as any as T; + } as unknown as T; } // Short circuit if we're not in production to enable easier debugging diff --git a/server/logging/tracer.ts b/server/logging/tracer.ts index 25eca250df..439eecce4d 100644 --- a/server/logging/tracer.ts +++ b/server/logging/tracer.ts @@ -14,6 +14,7 @@ function isExplicitlyNonReportable(error: Error): error is ReportableError { } type PrivateDatadogContext = { + // oxlint-disable-next-line @typescript-eslint/no-explicit-any req: Record & { _datadog?: { span?: Span; @@ -41,7 +42,10 @@ const getCurrentSpan = (): Span | null => tracer.scope().active(); * @param tags An object with the tags to add to the span * @param span An optional span object to add the tags to. If none provided,the current span will be used. */ -export function addTags(tags: Record, span?: Span | null): void { +export function addTags( + tags: Parameters[0], + span?: Span | null +): void { if (tracer) { const currentSpan = span || getCurrentSpan(); diff --git a/server/models/Event.ts b/server/models/Event.ts index b976c4633d..bd4b6de4d6 100644 --- a/server/models/Event.ts +++ b/server/models/Event.ts @@ -60,6 +60,7 @@ class Event extends IdModel< * Note that the `data` column will be visible to the client and API requests. */ @Column(DataType.JSONB) + // oxlint-disable-next-line @typescript-eslint/no-explicit-any data: Record | null; /** @@ -67,6 +68,7 @@ class Event extends IdModel< * used for arbitrary data associated with the event. */ @Column(DataType.JSONB) + // oxlint-disable-next-line @typescript-eslint/no-explicit-any changes: Record | null; // hooks diff --git a/server/models/helpers/ProsemirrorHelper.tsx b/server/models/helpers/ProsemirrorHelper.tsx index 04e53b2e6d..9b356de7fa 100644 --- a/server/models/helpers/ProsemirrorHelper.tsx +++ b/server/models/helpers/ProsemirrorHelper.tsx @@ -757,7 +757,7 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper { // Create a new document with the emoji removed from the text const json = doc.toJSON(); - function removeEmojiFromNode(node: any): any { + function removeEmojiFromNode(node: ProsemirrorData): ProsemirrorData { if (node.type === "text" && node.text && node.text.startsWith(emoji)) { return { ...node, @@ -768,7 +768,7 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper { let found = false; return { ...node, - content: node.content.map((child: any) => { + content: node.content.map((child) => { if (found) { return child; } @@ -783,7 +783,7 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper { return node; } - const modifiedJson = removeEmojiFromNode(json); + const modifiedJson = removeEmojiFromNode(json as ProsemirrorData); return { emoji, doc: Node.fromJSON(schema, modifiedJson), @@ -798,7 +798,7 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper { * @returns A cleanup function to restore the global environment. */ public static patchGlobalEnv(domWindow: JSDOM["window"]) { - const g = global as any; + const g = global as unknown as Record; const globalParams = { window: g.window, diff --git a/server/queues/tasks/EmailTask.ts b/server/queues/tasks/EmailTask.ts index a026e3bd1c..29a2f58db2 100644 --- a/server/queues/tasks/EmailTask.ts +++ b/server/queues/tasks/EmailTask.ts @@ -3,7 +3,7 @@ import { BaseTask } from "./base/BaseTask"; type Props = { templateName: string; - props: Record; + props: Record; }; export default class EmailTask extends BaseTask { diff --git a/server/services/websockets.ts b/server/services/websockets.ts index d9cf4c7efd..5cfac37391 100644 --- a/server/services/websockets.ts +++ b/server/services/websockets.ts @@ -56,7 +56,7 @@ export default function init( if (ioHandleUpgrade) { server.removeListener( "upgrade", - ioHandleUpgrade as (...args: any[]) => void + ioHandleUpgrade as (...args: unknown[]) => void ); } diff --git a/server/storage/database.ts b/server/storage/database.ts index 89adbfba1e..f533fa6296 100644 --- a/server/storage/database.ts +++ b/server/storage/database.ts @@ -237,7 +237,8 @@ export function monkeyPatchSequelizeErrorsForJest(instance: Sequelize) { return instance; } - const sequelizeVersion = (Sequelize as any).version; + const sequelizeVersion = (Sequelize as unknown as { version: string }) + .version; const major = sequelizeVersion.split(".").map(Number)[0]; if (major >= 7) { @@ -250,12 +251,13 @@ export function monkeyPatchSequelizeErrorsForJest(instance: Sequelize) { } const origQueryFunc = instance.query.bind(instance); - instance.query = (async (...args: any[]) => { + instance.query = (async (...args: Parameters) => { try { - return await origQueryFunc(...(args as Parameters)); - } catch (err: any) { + return await origQueryFunc(...args); + } catch (err) { // Ensure error appears in Jest output, not swallowed by Sequelize internals - Logger.error(err.message, err.parent); + const error = err as Error & { parent?: Error }; + Logger.error(error.message, error.parent ?? error); throw err; } }) as typeof instance.query; diff --git a/server/tools/util.ts b/server/tools/util.ts index be8ff3281f..a18cc7f6af 100644 --- a/server/tools/util.ts +++ b/server/tools/util.ts @@ -86,6 +86,7 @@ export function error(err: unknown): CallToolResult { * @param handler - the handler function to wrap. * @returns the wrapped handler with tracing enabled. */ +/* oxlint-disable @typescript-eslint/no-explicit-any */ export function withTracing any>( toolName: string, handler: F @@ -107,6 +108,7 @@ export function withTracing any>( return handler.apply(this, args); } as F); } +/* oxlint-enable @typescript-eslint/no-explicit-any */ /** * Builds a map from document ID to its zero-based index among siblings, diff --git a/server/utils/getInstallationInfo.ts b/server/utils/getInstallationInfo.ts index 479cf5fe7a..be0904756d 100644 --- a/server/utils/getInstallationInfo.ts +++ b/server/utils/getInstallationInfo.ts @@ -23,11 +23,14 @@ export async function getVersionInfo(currentVersion: string): Promise<{ // Continue fetching pages until the required versions are found or no more pages while (nextUrl) { const response = await fetch(nextUrl); - const data = await response.json(); + const data = (await response.json()) as { + results: { name: string }[]; + next?: string | null; + }; // Map and filter the versions to keep only full releases const pageVersions = data.results - .map((result: any) => result.name) + .map((result) => result.name) .filter(isFullReleaseVersion); allVersions = allVersions.concat(pageVersions); diff --git a/server/validation.ts b/server/validation.ts index ff8e18a2b9..5ba5f54cb0 100644 --- a/server/validation.ts +++ b/server/validation.ts @@ -55,7 +55,11 @@ export function assertKeysIn( Object.keys(obj).forEach((key) => assertIn(key, Object.values(type))); } -export const assertSort = (value: string, model: any, message?: string) => { +export const assertSort = ( + value: string, + model: { rawAttributes: Record }, + message?: string +) => { if (!Object.keys(model.rawAttributes).includes(value)) { throw ValidationError( message ?? `${String(value)} is not a valid sort field` diff --git a/shared/editor/lib/Extension.ts b/shared/editor/lib/Extension.ts index 2aaf7bf6e0..db13071099 100644 --- a/shared/editor/lib/Extension.ts +++ b/shared/editor/lib/Extension.ts @@ -13,9 +13,11 @@ export type WidgetProps = { }; export default class Extension { + // oxlint-disable-next-line @typescript-eslint/no-explicit-any options: any; editor: Editor; + // oxlint-disable-next-line @typescript-eslint/no-explicit-any constructor(options: Record = {}) { this.options = { ...this.defaultOptions, diff --git a/shared/env.ts b/shared/env.ts index c7bfed84f5..b64f49042e 100644 --- a/shared/env.ts +++ b/shared/env.ts @@ -1,3 +1,4 @@ const env = typeof window === "undefined" ? process.env : window.env; +// oxlint-disable-next-line @typescript-eslint/no-explicit-any export default env as Record; diff --git a/shared/types.ts b/shared/types.ts index f6c5b916a7..6bf0f4313a 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -688,15 +688,17 @@ export type JSONValue = export type JSONObject = { [x: string]: JSONValue }; +export type ProsemirrorMark = { + type: string; + attrs?: JSONObject; +}; + export type ProsemirrorData = { type: string; content?: ProsemirrorData[]; text?: string; attrs?: JSONObject; - marks?: { - type: string; - attrs?: JSONObject; - }[]; + marks?: ProsemirrorMark[]; }; export type ProsemirrorDoc = {