Files
3x-ui/frontend/eslint.config.js
PathGao 55f0281692 chore(lint): forbid the Number-or-clamp idiom in direct-write settings pages (#6129)
* chore(lint): forbid the Number-or-clamp idiom in direct-write settings pages

Follow-up promised in #6127's review thread: the settings and xray
pages write numeric changes straight into state, so a regressed
handler silently ships the cleared-port bug again. A scoped
no-restricted-syntax rule now rejects Number(...) || N inside an
onChange attribute in those directories, pointing at onNumber().

The one remaining match, the Telegram notify interval, moves onto the
helper with its floor intact: clearing now keeps the stored count
instead of writing 1, and Math.max still clamps typed values. Form
modals that stage values behind Zod keep their deliberate
clear-means-zero semantics; the rule deliberately does not apply
there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(lint): widen the numeric-clamp guard to the shapes that actually drift

From review: the rule matched only the Number-or-literal shape, while
two semantically identical ternary sites already lived inside its own
directories, so 'zero suppressions' reflected the selector's
narrowness rather than a clean subtree. The rule now catches the
ternary typeof form and the nullish-coalescing form too, is anchored
to InputNumber elements so its message can never point a ChangeEvent
handler at a number-typed helper, and documents the extracted-handler
shape it cannot see.

The xray form modals stage values behind Zod like the clients modals
do, so a follow-up config object exempts them explicitly instead of
the comment claiming they were never in scope.

BasicsTab's Happy Eyeballs try-delay — the one genuine direct-write
ternary — moves onto onNumber: clearing keeps the stored delay
instead of writing 0, and 0 stays reachable by typing it. The
Telegram interval gains precision={0} so a typed decimal cannot
compose an @every value its own parser rejects on reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:02:36 +02:00

90 lines
3.8 KiB
JavaScript

import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import globals from 'globals';
export default [
{ ignores: ['node_modules/**', '../internal/web/dist/**'] },
js.configs.recommended,
...tseslint.configs.recommended.map((config) => ({
...config,
files: ['**/*.{ts,tsx}'],
})),
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
},
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
},
},
rules: {
...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
// Zod migration goal (Step 7): every production module is held to
// strict no-explicit-any. The two legacy class files at the bottom
// of the rule list keep their existing file-level eslint-disable
// until DBInbound is migrated off Inbound.toInbound() — see the
// migration spec Non-Goals section.
'@typescript-eslint/no-explicit-any': 'error',
'no-empty': ['error', { allowEmptyCatch: true }],
'react-hooks/set-state-in-effect': 'off',
'react-hooks/purity': 'off',
'react-hooks/react-compiler': 'off',
'react-hooks/preserve-manual-memoization': 'off',
'react-hooks/immutability': 'off',
'react-hooks/refs': 'off',
},
},
{
files: ['**/*.tsx'],
plugins: { 'jsx-a11y': jsxA11y },
rules: {
...jsxA11y.flatConfigs.recommended.rules,
'jsx-a11y/no-autofocus': 'off',
},
},
{
// The settings and xray pages write numeric InputNumber changes straight
// into state, so a null-collapsing handler (`Number(v) || N`, or the
// ternary `typeof v === 'number' ? v : N`) turns a cleared field into a
// stored N — the cleared-port bug, #6121. Handlers here go through
// onNumber() (src/utils/onNumber.ts) instead. Known limit: a handler
// extracted into a variable and passed as onChange={handler} is not
// matched; the inline shapes below are the ones that drift in practice.
files: ['src/pages/settings/**/*.tsx', 'src/pages/xray/**/*.tsx'],
rules: {
'no-restricted-syntax': ['error', {
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="||"] > CallExpression[callee.name="Number"]',
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
}, {
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] ConditionalExpression[test.left.operator="typeof"][alternate.type="Literal"]',
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
}, {
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="??"][right.type="Literal"]',
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
}],
},
},
{
// The xray form modals (OutboundFormModal, BalancerFormModal,
// DnsServerModal, WarpModal, …) stage values behind Zod validation like
// the clients/inbounds modals do, and some of their fields carry a
// deliberate clear-means-zero semantic — the direct-write rule above
// does not apply to them.
files: ['src/pages/xray/**/*Modal.tsx'],
rules: {
'no-restricted-syntax': 'off',
},
},
];