From df4d4824c6a413a3ec8201d1206f9f699e9c9180 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 26 Oct 2026 00:13:01 -0700 Subject: [PATCH] perf(lint): skip JS plugin rules on files that cannot match (#33813) Each custom oxlint JS rule now checks the file text first and returns no visitors when it cannot report, so oxlint skips the AST transfer to JS. app-store-performance only resolves scopes for tracked hook names. anti-slop rules are wrapped with per-rule text prefilters. react-doctor rules are off for folders without React (the old cloud/** ignore never matched: config/ globs need a leading **/). Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/oxlint-anti-slop.json | 2 +- config/oxlint-plugins/anti-slop-prefilter.mjs | 45 +++++++++++++++++++ .../oxlint-plugins/app-store-performance.mjs | 31 ++++++++----- config/oxlint-plugins/ime-enter-guard.mjs | 4 ++ .../quadratic-buffer-concat.mjs | 5 +++ .../renderer-scrollbar-style.mjs | 4 ++ config/oxlint-react-doctor.json | 37 ++++++++++++++- config/scripts/anti-slop-prefilter.test.mjs | 44 ++++++++++++++++++ 8 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 config/oxlint-plugins/anti-slop-prefilter.mjs create mode 100644 config/scripts/anti-slop-prefilter.test.mjs diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json index cfe05973059..bfa526238b7 100644 --- a/config/oxlint-anti-slop.json +++ b/config/oxlint-anti-slop.json @@ -4,7 +4,7 @@ "jsPlugins": [ { "name": "anti-slop", - "specifier": "../.anti-slop-plugin/index.ts" + "specifier": "./oxlint-plugins/anti-slop-prefilter.mjs" } ], "categories": { diff --git a/config/oxlint-plugins/anti-slop-prefilter.mjs b/config/oxlint-plugins/anti-slop-prefilter.mjs new file mode 100644 index 00000000000..388714131a6 --- /dev/null +++ b/config/oxlint-plugins/anti-slop-prefilter.mjs @@ -0,0 +1,45 @@ +// Skips each anti-slop rule on files whose text cannot match it; when every rule skips, oxlint never transfers the AST to JS. +import antiSlop from '../../.anti-slop-plugin/index.ts' + +// Identifiers may be written with \u escapes, which hide them from a plain text search. +const hasEscape = (text) => text.includes('\\u') + +const REQUIRED_TEXT = { + 'no-module-mocking': (text) => /mock/i.test(text), + 'no-object-parameters': (text) => text.includes('object'), + 'no-reduce-accumulator-copy': (text) => text.includes('reduce'), + 'no-reflect-apply': (text) => text.includes('Reflect'), + 'no-reflect-get': (text) => text.includes('Reflect'), + 'no-shape-in-symbol-names': (text) => /shape/i.test(text), + 'no-unknown-type-aliases': (text) => text.includes('unknown') && /\btype\b/.test(text), + 'no-widen-then-assert': (text) => /\b(?:unknown|any|object)\b/.test(text) && /\bas\b| [ + name, + REQUIRED_TEXT[name] ? withPrefilter(rule, REQUIRED_TEXT[name]) : rule + ]) + ) +} diff --git a/config/oxlint-plugins/app-store-performance.mjs b/config/oxlint-plugins/app-store-performance.mjs index b46ca7c15bf..267bb34b7bf 100644 --- a/config/oxlint-plugins/app-store-performance.mjs +++ b/config/oxlint-plugins/app-store-performance.mjs @@ -82,6 +82,7 @@ function unwrapShallowSelector(selector, state) { if ( selector?.type === 'CallExpression' && selector.callee.type === 'Identifier' && + state.hookNames.has(selector.callee.name) && state.shallowHooks.has(variableFor(selector.callee, state)) ) { return { selector: selector.arguments[0], shallow: true } @@ -201,6 +202,7 @@ function createRuleState(sourceCode) { return { appStoreHooks: new Set(), shallowHooks: new Set(), + hookNames: new Set(), sourceCode, deferredCalls: [] } @@ -213,20 +215,22 @@ function recordImports(node, state) { continue } const imported = identifierName(specifier.imported) - const variable = variableFor(specifier.local, state) - if (!imported || !variable) { - continue - } - if (source === 'zustand/react/shallow' && imported === 'useShallow') { - state.shallowHooks.add(variable) - } + const shallow = source === 'zustand/react/shallow' && imported === 'useShallow' // useAppStore is the app store wherever it is re-exported from; sibling // stores are trusted by naming convention only when they come from this codebase. - if ( - STORE_HOOK_NAME.test(imported) && + const store = + STORE_HOOK_NAME.test(imported ?? '') && !NON_STORE_HOOKS.has(imported) && (imported === 'useAppStore' || isLocalModuleSource(source)) - ) { + // Why: getScope builds the whole scope tree, so only resolve bindings this rule tracks. + const variable = shallow || store ? variableFor(specifier.local, state) : null + if (!variable) { + continue + } + state.hookNames.add(specifier.local.name) + if (shallow) { + state.shallowHooks.add(variable) + } else { state.appStoreHooks.add(variable) } } @@ -235,6 +239,7 @@ function recordImports(node, state) { function isAppStoreCall(node, state) { return ( node.callee.type === 'Identifier' && + state.hookNames.has(node.callee.name) && state.appStoreHooks.has(variableFor(node.callee, state)) && node.optional !== true ) @@ -349,8 +354,14 @@ function noNestedFreshUnderShallowRule(sourceCode) { }) } +// Files that never name a useStore hook cannot match; returning no visitors skips AST transfer. +const STORE_HOOK_TEXT = /use[A-Z][A-Za-z0-9]*Store/ + function bindContext(createVisitors) { return (context) => { + if (!STORE_HOOK_TEXT.test(context.sourceCode.text)) { + return {} + } const visitors = createVisitors(context.sourceCode) for (const [nodeType, visit] of Object.entries(visitors)) { visitors[nodeType] = visit.bind(context) diff --git a/config/oxlint-plugins/ime-enter-guard.mjs b/config/oxlint-plugins/ime-enter-guard.mjs index 87eebe96d8d..e28cd773e62 100644 --- a/config/oxlint-plugins/ime-enter-guard.mjs +++ b/config/oxlint-plugins/ime-enter-guard.mjs @@ -89,6 +89,10 @@ function testsEnter(text) { } function createImeEnterGuardRule(context) { + // Reports need an Enter test somewhere in the file; returning no visitors skips AST transfer. + if (!testsEnter(context.sourceCode.text)) { + return {} + } const localFunctionTexts = new Map() const textElementNames = new Set() const candidates = [] diff --git a/config/oxlint-plugins/quadratic-buffer-concat.mjs b/config/oxlint-plugins/quadratic-buffer-concat.mjs index b2399345f5b..8f60f4b6b65 100644 --- a/config/oxlint-plugins/quadratic-buffer-concat.mjs +++ b/config/oxlint-plugins/quadratic-buffer-concat.mjs @@ -207,6 +207,11 @@ function quadraticAccumulator(context, call, loop, declarations, assignedRoots) } function createRule(context) { + const text = context.sourceCode.text + // Returning no visitors when Buffer.concat cannot appear skips AST transfer to JS. + if (!text.includes('Buffer') || !text.includes('concat')) { + return {} + } const declarations = new Map() const assignedRootsByLoop = new WeakMap() const recordBindings = (pattern, owner) => { diff --git a/config/oxlint-plugins/renderer-scrollbar-style.mjs b/config/oxlint-plugins/renderer-scrollbar-style.mjs index 4938d66ef79..3fb64ff9099 100644 --- a/config/oxlint-plugins/renderer-scrollbar-style.mjs +++ b/config/oxlint-plugins/renderer-scrollbar-style.mjs @@ -265,6 +265,10 @@ function jsxElementReports(node) { function bindContext(createVisitors) { return (context) => { + // Every vertical scroll class and style names overflow; returning no visitors skips AST transfer. + if (!context.sourceCode.text.includes('overflow')) { + return {} + } const visitors = createVisitors() for (const [nodeType, visit] of Object.entries(visitors)) { visitors[nodeType] = visit.bind(context) diff --git a/config/oxlint-react-doctor.json b/config/oxlint-react-doctor.json index c39f0d51bf2..c29dc78c46b 100644 --- a/config/oxlint-react-doctor.json +++ b/config/oxlint-react-doctor.json @@ -25,5 +25,40 @@ "react-doctor/zustand-no-mutating-state": "warn", "react-doctor/zustand-no-whole-store-destructure": "warn" }, - "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "cloud/**"] + "overrides": [ + { + // These processes have no React; linting them only pays AST transfer to JS. + // Paths outside config/ are matched absolute, so globs need a leading **/. + "files": [ + "**/src/main/**", + "**/src/shared/**", + "**/src/preload/**", + "**/src/cli/**", + "**/src/wsl-guest/**", + "**/cloud/**" + ], + "rules": { + "react-doctor/effect-needs-cleanup": "off", + "react-doctor/no-array-index-as-key": "off", + "react-doctor/no-adjust-state-on-prop-change": "off", + "react-doctor/no-create-store-in-render": "off", + "react-doctor/no-derived-state-effect": "off", + "react-doctor/no-initialize-state": "off", + "react-doctor/no-side-effect-in-state-updater-function": "off", + "react-doctor/no-unstable-nested-components": "off", + "react-doctor/zustand-no-fresh-selector-result": "off", + "react-doctor/zustand-no-get-during-initialization": "off", + "react-doctor/zustand-no-mutating-state": "off", + "react-doctor/zustand-no-whole-store-destructure": "off" + } + }, + { + // react-doctor tags this rule react-jsx-only; it visits every identifier, so skip files without JSX. + "files": ["**/*.{ts,mts,cts,js,mjs,cjs}"], + "rules": { + "react-doctor/no-unstable-nested-components": "off" + } + } + ], + "ignorePatterns": ["**/node_modules", "**/dist", "**/out"] } diff --git a/config/scripts/anti-slop-prefilter.test.mjs b/config/scripts/anti-slop-prefilter.test.mjs new file mode 100644 index 00000000000..e4a790dc0e9 --- /dev/null +++ b/config/scripts/anti-slop-prefilter.test.mjs @@ -0,0 +1,44 @@ +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import path from 'node:path' +import { beforeAll, expect, it } from 'vitest' +import { runOxlintPluginOnSources } from './oxlint-plugin-test-runner.mjs' + +const pluginPath = path.resolve('config/oxlint-plugins/anti-slop-prefilter.mjs') + +// Each source must still reach its rule through the text prefilter. +const violations = [ + ['no-module-mocking', "import { vi } from 'vitest'; vi.doMock('module');"], + ['no-object-parameters', 'export function read(value: object): void {}'], + ['no-reduce-accumulator-copy', 'export const all = [[1]].reduce((acc, x) => acc.concat(x), []);'], + ['no-reflect-apply', 'export const r = Reflect.apply(Math.max, null, [1]);'], + ['no-reflect-get', 'export const r = Reflect.get({ a: 1 }, "a");'], + ['no-shape-in-symbol-names', 'export const rowShape = 1;'], + ['no-shape-in-symbol-names', 'export const row\\u0053hape = 1;'], + ['no-unknown-type-aliases', 'export type Payload = unknown;'], + [ + 'no-widen-then-assert', + 'const value = {}; const wide: unknown = value; export const id = wide as { id: string };' + ] +] + +let diagnostics +beforeAll(() => { + if (!existsSync(path.resolve('.anti-slop-plugin/index.ts'))) { + execFileSync(process.execPath, ['config/scripts/sync-anti-slop-plugin.mjs']) + } + diagnostics = runOxlintPluginOnSources({ + pluginName: 'anti-slop', + pluginPath, + rules: Object.fromEntries(violations.map(([rule]) => [`anti-slop/${rule}`, 'error'])), + sources: violations.map(([, source]) => source), + extension: 'ts' + }) +}) + +it.each(violations.map((violation, index) => [...violation, index]))( + '%s still reports through the prefilter: %s', + (rule, _source, index) => { + expect(diagnostics[index].map((diagnostic) => diagnostic.code)).toContain(`anti-slop(${rule})`) + } +)