This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 3591d7e4b77 Tidy the ESLint config before the oxlint move (#76477)
3591d7e4b77 is described below

commit 3591d7e4b77668ca89212dcd8ddb9ab76900f1c8
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Nov 11 09:41:18 2026 -0500

    Tidy the ESLint config before the oxlint move (#76477)
    
    * UI: Tidy the ESLint config before the oxlint move
    
    Moving rules to oxlint next is easier to review when the ESLint config only
    holds rules that do something and only uses APIs oxlint can load. On the 
current
    code pnpm lint output stays identical: the same 30 messages, warnings
    included.
    
    The removed "off" entries turned off rules nothing enabled, so
    @oxlint/migrate would only carry them across as noise. The core
    no-empty-function rule reported every hit a second time next to the
    typescript-eslint version. no-octal cannot fire because legacy octal
    literals do not parse in modules, and react/sort-prop-types has no PropTypes
    to check.
    
    oxlint does not lint JSON, so key sorting for the locale files moves into
    the check-i18n-json hook, which already rewrites every locale file. It uses
    the same natural-compare key as breeze ui, checked against the npm package,
    and leaves every current locale file unchanged. The translation completeness
    warning is dropped because breeze ui check-translation-completeness already
    reports it in CI.
    
    no-restricted-syntax selectors do not carry over to oxlint. The React
    namespace check becomes a rule in a local plugin, in the shape oxlint's
    jsPlugins load and without the runtime @typescript-eslint/utils dependency,
    because no built-in rule can ban an open-ended React.* namespace. The FC 
check
    moves to the built-in no-restricted-types instead of a second custom rule. 
It
    now bans FC as a type anywhere, so the two plugin loaders that held loaded
    components in FC use ComponentType, which also covers class components.
    
    The rem-in-props rule is dropped instead of ported. Nothing records why it 
was
    added, it only looked at height, size and width, it has no hits, and its
    autofix wrote invalid JSX. Consistent sizing is better enforced through 
Chakra
    size tokens.
    
    Co-Authored-By: Claude <[email protected]>
    
    * Name the natural-sort bucket boundary and lock the two copies together
    
    Replace the literal 66 with the sort code of "0", and add a test that the
    check-i18n-json hook's natural_sort_key stays identical to breeze's copy.
    
    Co-Authored-By: Claude <[email protected]>
    
    ---------
    
    Co-authored-by: Claude <[email protected]>
---
 airflow-core/src/airflow/ui/eslint.config.js       |   6 -
 airflow-core/src/airflow/ui/package.json           |   4 -
 airflow-core/src/airflow/ui/pnpm-lock.yaml         | 150 ------------------
 .../ui/rules/{off.js => airflow-plugin/index.js}   |  19 ++-
 .../airflow-plugin/no-react-namespace-types.js     |  59 ++++++++
 .../no-react-namespace-types.test.js               |  38 +++++
 airflow-core/src/airflow/ui/rules/core.js          |  90 +++--------
 airflow-core/src/airflow/ui/rules/i18n.js          | 167 ---------------------
 airflow-core/src/airflow/ui/rules/jsonc.js         |  45 ------
 airflow-core/src/airflow/ui/rules/levels.js        |   3 -
 airflow-core/src/airflow/ui/rules/perfectionist.js |   3 -
 airflow-core/src/airflow/ui/rules/react.js         |   7 -
 airflow-core/src/airflow/ui/rules/rem.js           | 146 ------------------
 airflow-core/src/airflow/ui/rules/typescript.js    | 150 +++++-------------
 airflow-core/src/airflow/ui/rules/unicorn.js       |  21 +--
 .../components/PluginActions/PluginReactAction.tsx |   6 +-
 .../src/airflow/ui/src/pages/ReactPlugin.tsx       |   4 +-
 airflow-core/src/airflow/ui/tsconfig.dev.json      |   2 +-
 .../src/airflow_breeze/commands/ui_commands.py     |   7 +-
 scripts/ci/prek/check_i18n_json.py                 |  65 +++++++-
 scripts/ci/prek/ts_compile_lint_ui.py              |   4 +-
 scripts/tests/ci/prek/test_check_i18n_json.py      |  98 ++++++++++++
 22 files changed, 333 insertions(+), 761 deletions(-)

diff --git a/airflow-core/src/airflow/ui/eslint.config.js 
b/airflow-core/src/airflow/ui/eslint.config.js
index f1efb9a038f..d2b74a317b3 100644
--- a/airflow-core/src/airflow/ui/eslint.config.js
+++ b/airflow-core/src/airflow/ui/eslint.config.js
@@ -20,12 +20,9 @@
  * @import { FlatConfig } from "@typescript-eslint/utils/ts-eslint";
  */
 import { coreRules } from "./rules/core.js";
-import { i18nRules } from "./rules/i18n.js";
 import { i18nextRules } from "./rules/i18next.js";
-import { jsoncRules } from "./rules/jsonc.js";
 import { perfectionistRules } from "./rules/perfectionist.js";
 import { reactRules } from "./rules/react.js";
-import { remRules } from "./rules/rem.js";
 import { stylisticRules } from "./rules/stylistic.js";
 import { typescriptRules } from "./rules/typescript.js";
 import { unicornRules } from "./rules/unicorn.js";
@@ -44,9 +41,6 @@ export default /** @type {const} @satisfies 
{ReadonlyArray<FlatConfig.Config>} *
   perfectionistRules,
   reactRules,
   stylisticRules,
-  remRules,
   unicornRules,
   i18nextRules,
-  i18nRules,
-  jsoncRules,
 ]);
diff --git a/airflow-core/src/airflow/ui/package.json 
b/airflow-core/src/airflow/ui/package.json
index d059cd6367a..744a18e425a 100644
--- a/airflow-core/src/airflow/ui/package.json
+++ b/airflow-core/src/airflow/ui/package.json
@@ -78,11 +78,9 @@
   "devDependencies": {
     "@7nohe/openapi-react-query-codegen": "^1.6.2",
     "@eslint/compat": "^2.1.1",
-    "@eslint/js": "^10.0.1",
     "@playwright/test": "^1.64.1",
     "@rolldown/plugin-babel": "^0.3.0",
     "@stylistic/eslint-plugin": "^5.10.0",
-    "@tanstack/eslint-plugin-query": "^5.106.1",
     "@testing-library/jest-dom": "^7.0.1",
     "@testing-library/react": "^16.3.3",
     "@types/culori": "^4.0.1",
@@ -98,7 +96,6 @@
     "babel-plugin-react-compiler": "^1.0.0",
     "eslint": "^10.11.0",
     "eslint-plugin-i18next": "^6.1.5",
-    "eslint-plugin-jsonc": "^3.4.2",
     "eslint-plugin-jsx-a11y": "^6.10.2",
     "eslint-plugin-perfectionist": "^5.12.1",
     "eslint-plugin-react": "^7.37.5",
@@ -107,7 +104,6 @@
     "eslint-plugin-unicorn": "^76.0.0",
     "globals": "^17.14.0",
     "happy-dom": "^21.0.4",
-    "jsonc-eslint-parser": "^3.3.0",
     "msw": "^3.0.3",
     "openapi-merge-cli": "^2.0.3",
     "oxfmt": "0.75.0",
diff --git a/airflow-core/src/airflow/ui/pnpm-lock.yaml 
b/airflow-core/src/airflow/ui/pnpm-lock.yaml
index fa13a6a0436..c1e2f7da275 100644
--- a/airflow-core/src/airflow/ui/pnpm-lock.yaml
+++ b/airflow-core/src/airflow/ui/pnpm-lock.yaml
@@ -194,9 +194,6 @@ importers:
       '@eslint/compat':
         specifier: ^2.1.1
         version: 2.1.1([email protected]([email protected]))
-      '@eslint/js':
-        specifier: ^10.0.1
-        version: 10.0.1([email protected]([email protected]))
       '@playwright/test':
         specifier: ^1.64.1
         version: 1.64.1
@@ -206,9 +203,6 @@ importers:
       '@stylistic/eslint-plugin':
         specifier: ^5.10.0
         version: 5.10.0([email protected]([email protected]))
-      '@tanstack/eslint-plugin-query':
-        specifier: ^5.106.1
-        version: 5.106.1([email protected]([email protected]))([email protected])
       '@testing-library/jest-dom':
         specifier: ^7.0.1
         version: 7.0.1(@testing-library/[email protected])([email protected])
@@ -254,9 +248,6 @@ importers:
       eslint-plugin-i18next:
         specifier: ^6.1.5
         version: 6.1.5
-      eslint-plugin-jsonc:
-        specifier: ^3.4.2
-        version: 3.4.2([email protected]([email protected]))
       eslint-plugin-jsx-a11y:
         specifier: ^6.10.2
         version: 6.10.2([email protected]([email protected]))
@@ -281,9 +272,6 @@ importers:
       happy-dom:
         specifier: ^21.0.4
         version: 21.0.4
-      jsonc-eslint-parser:
-        specifier: ^3.3.0
-        version: 3.3.0
       msw:
         specifier: ^3.0.3
         version: 
3.0.3(@types/[email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
@@ -615,23 +603,10 @@ packages:
     resolution: {integrity: 
sha512-A+s89eP+yDAPkKjnIOfjlkGifg5J7GPn01nWx14UjBVWktfkxOU4975F9ZZ13xNxkGiblNcBaAqgKhnQrW6SWA==}
     engines: {node: ^20.19.0 || ^22.13.0 || >=24}
 
-  '@eslint/[email protected]':
-    resolution: {integrity: 
sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
-    engines: {node: ^20.19.0 || ^22.13.0 || >=24}
-    peerDependencies:
-      eslint: ^10.0.0
-    peerDependenciesMeta:
-      eslint:
-        optional: true
-
   '@eslint/[email protected]':
     resolution: {integrity: 
sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==}
     engines: {node: ^20.19.0 || ^22.13.0 || >=24}
 
-  '@eslint/[email protected]':
-    resolution: {integrity: 
sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
-    engines: {node: ^20.19.0 || ^22.13.0 || >=24}
-
   '@eslint/[email protected]':
     resolution: {integrity: 
sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==}
     engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -804,10 +779,6 @@ packages:
   '@open-draft/[email protected]':
     resolution: {integrity: 
sha512-s7/9ELP4aP9YZtW7RJaa0Xf3RISaRH9+EFN18FtykJYRHGNrl8f9ymvoNXzjhrjmftFvkQudtuDU9RYhk0xs3A==}
 
-  '@ota-meshi/[email protected]':
-    resolution: {integrity: 
sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==}
-    engines: {node: ^20.19.0 || ^22.13.0 || >=24}
-
   '@oxc-project/[email protected]':
     resolution: {integrity: 
sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==}
 
@@ -858,56 +829,48 @@ packages:
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@oxfmt/[email protected]':
     resolution: {integrity: 
sha512-bjSONIG1ba2xugr9oHQau0E6oPFYOMjPaa5hK/wpVC/Q1pcgf2BZMKwbuA8/cWzhWX5QV/UdPzD+rw1kQHpikQ==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@oxfmt/[email protected]':
     resolution: {integrity: 
sha512-wDksWQXC/KZucISyqlhBtvpW+pyHh8z0Cjm6Qxiv22XlllM2Bx5gJGDnPYjV+H0lL+o1iqLlEli8jmBuRCLcjw==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [ppc64]
     os: [linux]
-    libc: [glibc]
 
   '@oxfmt/[email protected]':
     resolution: {integrity: 
sha512-5JkNwViqaVuk4I+9+zgVrEYiHOAPXFxgDgP6iDnpdOBjm7XAHAOjSvolHZZAokWPx7CGOw8tKteXmn4WPIInaQ==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [riscv64]
     os: [linux]
-    libc: [glibc]
 
   '@oxfmt/[email protected]':
     resolution: {integrity: 
sha512-1EVqRIE0eZLB08j3ZCVN47Ors7LCXGmOI+3xJYgyoFUKUerD3Glxb+Q1Bp3lK8NeT5R3ZdS9uhX/wobNBYZ6jw==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [riscv64]
     os: [linux]
-    libc: [musl]
 
   '@oxfmt/[email protected]':
     resolution: {integrity: 
sha512-UJWRVNT6Yuw4ssLv2BwaBUWU1GWtByE+EnoHu8I+P1t8Kn+WozYU5WYOqb5aj8gbXJJ8gGZt+w40Hi/7L6iVLw==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [s390x]
     os: [linux]
-    libc: [glibc]
 
   '@oxfmt/[email protected]':
     resolution: {integrity: 
sha512-FHdvHhyz8ChBctBUvVGti49kSEwANd1PZQwlXUEoF9Ed4plxXxk/wWc0mfZmrze2lRgmME7ytDmVIOixYauQhg==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@oxfmt/[email protected]':
     resolution: {integrity: 
sha512-6MjtsF96s9mSBV0GEwUe4pOFPP2Mz7ahcHPAit0DSKL5fM6oMM7RG1zvYxwB7+SBkIxsWBRhhxuqmy7pibNwsA==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@oxfmt/[email protected]':
     resolution: {integrity: 
sha512-1D6T6aL1Pj+U5GLxqwV1fKjUJ5+FFm+bn7UrqNDMveZmWs0jo/3vc+dzzuV/jF/FdVo12gowODk3SnAO2YOwLg==}
@@ -941,10 +904,6 @@ packages:
     resolution: {integrity: 
sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
     engines: {node: '>=14'}
 
-  '@pkgr/[email protected]':
-    resolution: {integrity: 
sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
-    engines: {node: ^14.18.0 || >=16.0.0}
-
   '@playwright/[email protected]':
     resolution: {integrity: 
sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==}
     engines: {node: '>=20'}
@@ -991,42 +950,36 @@ packages:
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@rolldown/[email protected]':
     resolution: {integrity: 
sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@rolldown/[email protected]':
     resolution: {integrity: 
sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [ppc64]
     os: [linux]
-    libc: [glibc]
 
   '@rolldown/[email protected]':
     resolution: {integrity: 
sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [s390x]
     os: [linux]
-    libc: [glibc]
 
   '@rolldown/[email protected]':
     resolution: {integrity: 
sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@rolldown/[email protected]':
     resolution: {integrity: 
sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==}
     engines: {node: ^20.19.0 || >=22.12.0}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@rolldown/[email protected]':
     resolution: {integrity: 
sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==}
@@ -1075,15 +1028,6 @@ packages:
   '@swc/[email protected]':
     resolution: {integrity: 
sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
 
-  '@tanstack/[email protected]':
-    resolution: {integrity: 
sha512-wNCdC4uMHpEDP3GnylIUiBOco+NS53/OJiV17Of64Leg+YF4GbnLiMycVLsXMFW9iVKpnklPDt2ENkPIR0DB8A==}
-    peerDependencies:
-      eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
-      typescript: ^5.6.0 || ^6.0.0 || ^7.0.0
-    peerDependenciesMeta:
-      typescript:
-        optional: true
-
   '@tanstack/[email protected]':
     resolution: {integrity: 
sha512-erYv/UACddcdgjOL2ixmFdK6tO/AVBeUcTCOtX2N51yZpDQ2U7KJ+xgB/bCNfsF3uoCDTqGHEEbDiFsIO9YUmQ==}
 
@@ -2449,10 +2393,6 @@ packages:
   [email protected]:
     resolution: {integrity: 
sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
 
-  [email protected]:
-    resolution: {integrity: 
sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
-    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
-
   [email protected]:
     resolution: {integrity: 
sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
     engines: {node: '>=0.10.0'}
@@ -2563,27 +2503,10 @@ packages:
     resolution: {integrity: 
sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
     engines: {node: '>=12'}
 
-  [email protected]:
-    resolution: {integrity: 
sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ==}
-    engines: {node: '>=12'}
-    peerDependencies:
-      '@eslint/json': '*'
-      eslint: '*'
-      jsonc-eslint-parser: ^2.4.0 || ^3.0.0
-    peerDependenciesMeta:
-      '@eslint/json':
-        optional: true
-
   [email protected]:
     resolution: {integrity: 
sha512-xCTfstbK9ZpQ6UFT5S1s6zbZMhKn2o2jRSQYiU2UkV7wt8y1m3WjkUYGkGlipgRdFInYI9+LAqE+JQU/L73HmA==}
     engines: {node: '>=18.10.0'}
 
-  [email protected]:
-    resolution: {integrity: 
sha512-q5xzjCYFQuhFomTbctXzd3STfDJ8XduvaigjQmMEbP2gzSKrc58y3Fv6D775/cIK1/sRUn+4kpljiU2iW8k0zw==}
-    engines: {node: ^20.19.0 || ^22.13.0 || >=24}
-    peerDependencies:
-      eslint: '>=9.38.0'
-
   [email protected]:
     resolution: {integrity: 
sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
     engines: {node: '>=4.0'}
@@ -3200,10 +3123,6 @@ packages:
     engines: {node: '>=6'}
     hasBin: true
 
-  [email protected]:
-    resolution: {integrity: 
sha512-hYTGkHGNRZnXOFZ1urhINADoqDrGfpy53cjw+dxk84QE0pUDujQzeUeamNs6Mz44/TKD49z2x6/GVSu4ZrtA+Q==}
-    engines: {node: ^20.19.0 || ^22.13.0 || >=24}
-
   [email protected]:
     resolution: {integrity: 
sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
     engines: {node: '>=0.10.0'}
@@ -3282,28 +3201,24 @@ packages:
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   [email protected]:
     resolution: {integrity: 
sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   [email protected]:
     resolution: {integrity: 
sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   [email protected]:
     resolution: {integrity: 
sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   [email protected]:
     resolution: {integrity: 
sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
@@ -4229,10 +4144,6 @@ packages:
     resolution: {integrity: 
sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
     engines: {node: '>= 0.4'}
 
-  [email protected]:
-    resolution: {integrity: 
sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==}
-    engines: {node: ^14.18.0 || >=16.0.0}
-
   [email protected]:
     resolution: {integrity: 
sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
     engines: {node: '>=20'}
@@ -4423,10 +4334,6 @@ packages:
     peerDependencies:
       react: ^16.8.0  || ^17 || ^18 || ^19 || ^19.0.0-rc
 
-  [email protected]:
-    resolution: {integrity: 
sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==}
-    engines: {node: '>=18.12.0'}
-
   [email protected]:
     resolution: {integrity: 
sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
 
@@ -5107,17 +5014,8 @@ snapshots:
       mdn-data: 2.35.0
       source-map-js: 1.2.2
 
-  '@eslint/[email protected]([email protected]([email protected]))':
-    optionalDependencies:
-      eslint: 10.11.0([email protected])
-
   '@eslint/[email protected]': {}
 
-  '@eslint/[email protected]':
-    dependencies:
-      '@eslint/core': 1.2.1
-      levn: 0.4.1
-
   '@eslint/[email protected]':
     dependencies:
       '@eslint/core': 1.2.1
@@ -5299,8 +5197,6 @@ snapshots:
 
   '@open-draft/[email protected]': {}
 
-  '@ota-meshi/[email protected]': {}
-
   '@oxc-project/[email protected]': {}
 
   '@oxfmt/[email protected]':
@@ -5365,8 +5261,6 @@ snapshots:
   '@pkgjs/[email protected]':
     optional: true
 
-  '@pkgr/[email protected]': {}
-
   '@playwright/[email protected]':
     dependencies:
       playwright: 1.64.1
@@ -5442,15 +5336,6 @@ snapshots:
     dependencies:
       tslib: 2.8.1
 
-  
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
-    dependencies:
-      '@typescript-eslint/utils': 
8.72.1([email protected]([email protected]))([email protected])
-      eslint: 10.11.0([email protected])
-    optionalDependencies:
-      typescript: 6.0.3
-    transitivePeerDependencies:
-      - supports-color
-
   '@tanstack/[email protected]': {}
 
   '@tanstack/[email protected](@types/[email protected])([email protected])':
@@ -7267,8 +7152,6 @@ snapshots:
     dependencies:
       dequal: 2.0.3
 
-  [email protected]: {}
-
   [email protected]:
     dependencies:
       esutils: 2.0.3
@@ -7428,31 +7311,10 @@ snapshots:
 
   [email protected]: {}
 
-  
[email protected]([email protected]([email protected]))([email protected]):
-    dependencies:
-      eslint: 10.11.0([email protected])
-      esquery: 1.7.0
-      jsonc-eslint-parser: 3.3.0
-
   [email protected]:
     dependencies:
       requireindex: 1.1.0
 
-  [email protected]([email protected]([email protected])):
-    dependencies:
-      '@eslint-community/eslint-utils': 4.10.1([email protected]([email protected]))
-      '@eslint/core': 1.2.1
-      '@eslint/plugin-kit': 0.7.2
-      '@ota-meshi/ast-token-store': 0.3.0
-      diff-sequences: 29.6.3
-      eslint: 10.11.0([email protected])
-      eslint-json-compat-utils: 
0.2.3([email protected]([email protected]))([email protected])
-      jsonc-eslint-parser: 3.3.0
-      natural-compare: 1.4.0
-      synckit: 0.11.13
-    transitivePeerDependencies:
-      - '@eslint/json'
-
   [email protected]([email protected]([email protected])):
     dependencies:
       aria-query: 5.3.2
@@ -8179,12 +8041,6 @@ snapshots:
 
   [email protected]: {}
 
-  [email protected]:
-    dependencies:
-      acorn: 8.17.0
-      eslint-visitor-keys: 5.0.1
-      verkit: 0.3.2
-
   [email protected]: {}
 
   [email protected]:
@@ -9543,10 +9399,6 @@ snapshots:
 
   [email protected]: {}
 
-  [email protected]:
-    dependencies:
-      '@pkgr/core': 0.3.6
-
   [email protected]: {}
 
   [email protected]:
@@ -9751,8 +9603,6 @@ snapshots:
       lodash.debounce: 4.0.8
       react: 19.3.1
 
-  [email protected]: {}
-
   [email protected]:
     dependencies:
       '@types/unist': 3.0.3
diff --git a/airflow-core/src/airflow/ui/rules/off.js 
b/airflow-core/src/airflow/ui/rules/airflow-plugin/index.js
similarity index 67%
rename from airflow-core/src/airflow/ui/rules/off.js
rename to airflow-core/src/airflow/ui/rules/airflow-plugin/index.js
index f01f44ed55d..c6ba3606d68 100644
--- a/airflow-core/src/airflow/ui/rules/off.js
+++ b/airflow-core/src/airflow/ui/rules/airflow-plugin/index.js
@@ -16,14 +16,17 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { OFF } from "./levels.js";
 
 /**
- * Generates an object with all the given rules set to {@link OFF}.
- *
- * @template {string} Rule
- * @param {ReadonlyArray<Rule>} rules Array of rules to turn off.
- * @returns Object with rules set to {@link OFF}.
+ * Airflow UI lint rules, in the plugin shape both ESLint and oxlint 
(`jsPlugins`) load.
  */
-export const off = (...rules) =>
-  /** @type {Readonly<Record<Rule, typeof OFF>>} */ 
(Object.fromEntries(rules.map((rule) => [rule, OFF])));
+import { noReactNamespaceTypes } from "./no-react-namespace-types.js";
+
+export const airflowNamespace = "airflow";
+
+export default {
+  meta: { name: airflowNamespace },
+  rules: {
+    "no-react-namespace-types": noReactNamespaceTypes,
+  },
+};
diff --git 
a/airflow-core/src/airflow/ui/rules/airflow-plugin/no-react-namespace-types.js 
b/airflow-core/src/airflow/ui/rules/airflow-plugin/no-react-namespace-types.js
new file mode 100644
index 00000000000..9bcf3e3a2f8
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/rules/airflow-plugin/no-react-namespace-types.js
@@ -0,0 +1,59 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison -- AST node 
types are compared as strings so the plugin does not need 
`@typescript-eslint/utils` at runtime, which oxlint JS plugins cannot rely on. 
*/
+
+/**
+ * @import { TSESLint, TSESTree } from "@typescript-eslint/utils";
+ */
+
+/** Reported by `@typescript-eslint/no-restricted-types` with a 
component-specific message instead. */
+const componentTypes = new Set(["FC", "FunctionComponent", "VFC"]);
+
+/**
+ * Disallow the `React` type namespace. `@types/react` declares `export as 
namespace React`, so
+ * `React.ReactNode` type-checks even in files that never import React.
+ *
+ * @type {TSESLint.RuleModule<"noReactNamespaceTypes">}
+ */
+export const noReactNamespaceTypes = {
+  create: (context) => ({
+    /** @param {TSESTree.TSQualifiedName} node */
+    TSQualifiedName(node) {
+      if (
+        node.left.type === "Identifier" &&
+        node.left.name === "React" &&
+        !componentTypes.has(node.right.name)
+      ) {
+        context.report({ messageId: "noReactNamespaceTypes", node });
+      }
+    },
+  }),
+  defaultOptions: [],
+  meta: {
+    docs: {
+      description: "Disallow qualifying React types with the `React` 
namespace",
+    },
+    messages: {
+      noReactNamespaceTypes:
+        'Import React types by name instead of qualifying them with the 
`React` namespace: `import { type ReactElement } from "react"`, then use 
`ReactElement`.',
+    },
+    schema: [],
+    type: "problem",
+  },
+};
diff --git 
a/airflow-core/src/airflow/ui/rules/airflow-plugin/no-react-namespace-types.test.js
 
b/airflow-core/src/airflow/ui/rules/airflow-plugin/no-react-namespace-types.test.js
new file mode 100644
index 00000000000..65095e697f7
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/rules/airflow-plugin/no-react-namespace-types.test.js
@@ -0,0 +1,38 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import typescriptParser from "@typescript-eslint/parser";
+import { RuleTester } from "eslint";
+
+import { noReactNamespaceTypes } from "./no-react-namespace-types.js";
+
+const ruleTester = new RuleTester({ languageOptions: { parser: 
typescriptParser } });
+
+ruleTester.run("no-react-namespace-types", noReactNamespaceTypes, {
+  invalid: [
+    { code: "const node: React.ReactNode = null;", errors: [{ messageId: 
"noReactNamespaceTypes" }] },
+    { code: "type Element = React.JSX.Element;", errors: [{ messageId: 
"noReactNamespaceTypes" }] },
+    { code: "type SetState = typeof React.useState;", errors: [{ messageId: 
"noReactNamespaceTypes" }] },
+  ],
+  valid: [
+    "const node: ReactNode = null;",
+    "type Props = Chakra.BoxProps;",
+    "const Foo: React.FC = () => null;",
+    "type Foo = React.FunctionComponent | React.VFC;",
+  ],
+});
diff --git a/airflow-core/src/airflow/ui/rules/core.js 
b/airflow-core/src/airflow/ui/rules/core.js
index 0b8dc3419db..fb99da6449b 100644
--- a/airflow-core/src/airflow/ui/rules/core.js
+++ b/airflow-core/src/airflow/ui/rules/core.js
@@ -19,6 +19,7 @@
 /**
  * @import { FlatConfig } from "@typescript-eslint/utils/ts-eslint";
  */
+import airflowPlugin, { airflowNamespace } from "./airflow-plugin/index.js";
 import { ERROR, WARN } from "./levels.js";
 
 const allExtensions = "*.{j,t}s{x,}";
@@ -34,7 +35,25 @@ export const coreRules = /** @type {const} @satisfies 
{FlatConfig.Config} */ ({
     // Files in the root directory
     allExtensions,
   ],
+  plugins: { [airflowNamespace]: airflowPlugin },
   rules: {
+    /**
+     * Disallow the `React` type namespace. `@types/react` declares `export as
+     * namespace React`, so `React.ReactNode` type-checks even in files that
+     * never import React — only a syntax rule can catch those. `React.FC` and
+     * friends are reported by `@typescript-eslint/no-restricted-types`.
+     *
+     * @example
+     * ```typescript
+     * // ❌ Incorrect
+     * const node: React.ReactNode = null;
+     *
+     * // ✅ Correct
+     * const node: ReactNode = null;
+     * ```
+     */
+    [`${airflowNamespace}/no-react-namespace-types`]: ERROR,
+
     /**
      * Enforce getter and setter pairs in objects and classes.
      *
@@ -476,23 +495,6 @@ export const coreRules = /** @type {const} @satisfies 
{FlatConfig.Config} */ ({
      */
     "no-empty-character-class": ERROR,
 
-    /**
-     * Disallow empty functions.
-     *
-     * @example
-     * ```typescript
-     * // ❌ Incorrect
-     * const foo = function() {}
-     *
-     * // ✅ Correct
-     * const foo = () {
-     *   // At least this one has a comment
-     * }
-     * ```
-     * @see 
[no-empty-function](https://eslint.org/docs/latest/rules/no-empty-function)
-     */
-    "no-empty-function": ERROR,
-
     /**
      * Disallow empty destructuring patterns.
      *
@@ -783,23 +785,6 @@ export const coreRules = /** @type {const} @satisfies 
{FlatConfig.Config} */ ({
      */
     "no-object-constructor": ERROR,
 
-    /**
-     * Disallow old octal literals.
-     *
-     * @example
-     * ```typescript
-     * // ❌ Incorrect
-     * const num = 071;
-     * const result = 5 + 07;
-     *
-     * // ✅ Correct
-     * const num = 0o71;
-     * const result = 5 + 0o7;
-     * ```
-     * @see [no-octal](https://eslint.org/docs/latest/rules/no-octal)
-     */
-    "no-octal": ERROR,
-
     /**
      * Disallow reassignment of function parameters.
      *
@@ -885,43 +870,6 @@ export const coreRules = /** @type {const} @satisfies 
{FlatConfig.Config} */ ({
      */
     "no-restricted-globals": [ERROR, { message: "Use `globalThis` instead.", 
name: "window" }],
 
-    /**
-     * Disallow the `React` type namespace. `@types/react` declares `export as
-     * namespace React`, so `React.ReactNode` type-checks even in files that
-     * never import React — only a syntax rule can catch those.
-     *
-     * @example
-     * ```typescript
-     * // ❌ Incorrect
-     * const Foo: React.FC<FooProps> = ({ bar }) => <div>{bar}</div>;
-     * const node: React.ReactNode = null;
-     *
-     * // ✅ Correct
-     * const Foo = ({ bar }: FooProps) => <div>{bar}</div>;
-     * const node: ReactNode = null;
-     * ```
-     * @see 
[no-restricted-syntax](https://eslint.org/docs/latest/rules/no-restricted-syntax)
-     */
-    "no-restricted-syntax": [
-      ERROR,
-      {
-        message:
-          "Do not type components with `React.FC`. Annotate the props 
parameter instead: `const Foo = ({ bar }: FooProps) => ...`.",
-        selector: 
"TSQualifiedName[left.name='React'][right.name=/^(FC|FunctionComponent|VFC)$/]",
-      },
-      {
-        message:
-          "Do not type components with `FC`. Annotate the props parameter 
instead: `const Foo = ({ bar }: FooProps) => ...`.",
-        selector:
-          "VariableDeclarator[init.type='ArrowFunctionExpression'] > 
Identifier > TSTypeAnnotation > TSTypeReference > 
Identifier[name=/^(FC|FunctionComponent|VFC)$/]",
-      },
-      {
-        message:
-          'Import React types by name instead of qualifying them with the 
`React` namespace: `import { type ReactElement } from "react"`, then use 
`ReactElement`.',
-        selector: 
"TSQualifiedName[left.name='React']:not([right.name=/^(FC|FunctionComponent|VFC)$/])",
-      },
-    ],
-
     /**
      * Disallow assignment operators in `return` statements.
      *
diff --git a/airflow-core/src/airflow/ui/rules/i18n.js 
b/airflow-core/src/airflow/ui/rules/i18n.js
deleted file mode 100644
index bf71a66b65e..00000000000
--- a/airflow-core/src/airflow/ui/rules/i18n.js
+++ /dev/null
@@ -1,167 +0,0 @@
-/*!
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-/* eslint-disable @typescript-eslint/no-unsafe-argument */
-/* eslint-disable @typescript-eslint/no-unsafe-assignment */
-import * as jsoncParser from "jsonc-eslint-parser";
-import fs from "node:fs";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-
-export const i18nNamespace = "i18n";
-/**
- * Extract all nested keys from translation object
- * @param {Record<string, any>} obj
- * @param {string} [prefix]
- * @returns {string[]}
- */
-const getKeys = (obj, prefix = "") => {
-  if (Array.isArray(obj)) {
-    return [];
-  }
-
-  return Object.keys(obj).flatMap((key) => {
-    const newPrefix = prefix ? `${prefix}.${key}` : key;
-    const value = obj[key];
-
-    return typeof value === "object" && value !== null && !Array.isArray(value)
-      ? [newPrefix, ...getKeys(value, newPrefix)]
-      : [newPrefix];
-  });
-};
-
-// Path to locales directory
-const localesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 
"../public/i18n/locales");
-
-// Default language (English) as reference
-const defaultLanguage = "en";
-const defaultLanguageKeys = /** @type {Record<string, string[]>} */ ({});
-const defaultLanguageDir = path.join(localesDir, defaultLanguage);
-
-// Load translation keys from default language files
-fs.readdirSync(defaultLanguageDir)
-  .filter((file) => file.endsWith(".json"))
-  .forEach((jsonFile) => {
-    const ns = path.basename(jsonFile, ".json");
-    const filePath = path.join(defaultLanguageDir, jsonFile);
-    const fileContent = fs.readFileSync(filePath, "utf8");
-    const parsedJson = JSON.parse(fileContent);
-
-    if (typeof parsedJson === "object" && parsedJson !== null && 
!Array.isArray(parsedJson)) {
-      defaultLanguageKeys[ns] = getKeys(parsedJson);
-    }
-  });
-
-export const i18nPlugin = {
-  files: ["public/i18n/locales/**/*.json"],
-  rules: {
-    "check-translations-completeness": {
-      /** @param 
{import('@typescript-eslint/utils').TSESLint.RuleContext<'missingKeys' | 
'fileError', []>} context */
-      create(context) {
-        return {
-          /** @param {import('@typescript-eslint/utils').TSESTree.Program} 
node */
-          Program(node) {
-            // Get language code and namespace from file path
-            const currentFilePath = context.filename;
-            const langCode = path.dirname(path.relative(localesDir, 
currentFilePath));
-            const namespace = path.basename(currentFilePath, ".json");
-
-            if (langCode === defaultLanguage) {
-              return;
-            }
-
-            // Get keys from current file
-            const referenceKeys = defaultLanguageKeys[namespace];
-            let langKeys;
-
-            try {
-              const parsedLangJson = JSON.parse(context.sourceCode.text);
-
-              if (
-                typeof parsedLangJson === "object" &&
-                parsedLangJson !== null &&
-                !Array.isArray(parsedLangJson)
-              ) {
-                langKeys = getKeys(parsedLangJson);
-              } else {
-                context.report({
-                  data: { error: "Invalid JSON object.", filePath: 
currentFilePath },
-                  messageId: "fileError",
-                  node,
-                });
-
-                return;
-              }
-            } catch (error) {
-              const message = error instanceof Error ? error.message : 
String(error);
-
-              context.report({
-                data: { error: message, filePath: currentFilePath },
-                messageId: "fileError",
-                node,
-              });
-
-              return;
-            }
-
-            // Check for missing translations
-            const langKeysSet = new Set(langKeys);
-            const missingKeys = referenceKeys.filter((key) => 
!langKeysSet.has(key));
-
-            if (missingKeys.length > 0) {
-              context.report({
-                data: { keys: missingKeys.join(", "), lang: langCode, 
namespace },
-                messageId: "missingKeys",
-                node,
-              });
-            }
-          },
-        };
-      },
-      meta: {
-        docs: {
-          category: "Best Practices",
-          description: "Ensures non-default lang files have all keys from 
default.",
-          recommended: "warn",
-        },
-        messages: {
-          fileError: "Failed to read/parse {{filePath}}. Error: {{error}}",
-          missingKeys: "Lang '{{lang}}' (namespace: {{namespace}}) missing 
keys: {{keys}}",
-        },
-        type: "problem",
-      },
-    },
-  },
-};
-
-/** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */
-export const i18nRules = {
-  files: ["public/i18n/locales/**/*.json"],
-  languageOptions: {
-    parser: jsoncParser,
-    parserOptions: {
-      extraFileExtensions: [".json"],
-    },
-  },
-  plugins: {
-    [i18nNamespace]: i18nPlugin,
-  },
-  rules: {
-    [`${i18nNamespace}/check-translations-completeness`]: "warn",
-  },
-};
diff --git a/airflow-core/src/airflow/ui/rules/jsonc.js 
b/airflow-core/src/airflow/ui/rules/jsonc.js
deleted file mode 100644
index a39418eb31c..00000000000
--- a/airflow-core/src/airflow/ui/rules/jsonc.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/*!
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import jsonc from "eslint-plugin-jsonc";
-
-import { ERROR } from "./levels.js";
-
-export const jsoncNamespace = "jsonc";
-
-/** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */
-export const jsoncRules = {
-  files: ["public/i18n/locales/**/*.json"],
-  plugins: {
-    [jsoncNamespace]: jsonc,
-  },
-  rules: {
-    /**
-     * Enforce sorted keys.
-     *
-     * @see 
[jsonc/sort-keys](https://ota-meshi.github.io/eslint-plugin-jsonc/rules/sort-keys.html)
-     */
-    [`${jsoncNamespace}/sort-keys`]: [
-      ERROR,
-      "asc",
-      {
-        natural: true,
-      },
-    ],
-  },
-};
diff --git a/airflow-core/src/airflow/ui/rules/levels.js 
b/airflow-core/src/airflow/ui/rules/levels.js
index 99f610b70ca..5c972092a46 100644
--- a/airflow-core/src/airflow/ui/rules/levels.js
+++ b/airflow-core/src/airflow/ui/rules/levels.js
@@ -17,9 +17,6 @@
  * under the License.
  */
 
-/** Disabled rule */
-export const OFF = 0;
-
 /** Rule will show a warning. */
 export const WARN = 1;
 
diff --git a/airflow-core/src/airflow/ui/rules/perfectionist.js 
b/airflow-core/src/airflow/ui/rules/perfectionist.js
index 0451f79b8ac..f8b524ce58c 100644
--- a/airflow-core/src/airflow/ui/rules/perfectionist.js
+++ b/airflow-core/src/airflow/ui/rules/perfectionist.js
@@ -22,7 +22,6 @@
 import perfectionist from "eslint-plugin-perfectionist";
 
 import { ERROR } from "./levels.js";
-import { off } from "./off.js";
 
 /**
  * ESLint `perfectionist` namespace.
@@ -140,7 +139,5 @@ export const perfectionistRules = /** @type {const} 
@satisfies {FlatConfig.Confi
         ],
       },
     ],
-
-    ...off("sort-keys"),
   },
 });
diff --git a/airflow-core/src/airflow/ui/rules/react.js 
b/airflow-core/src/airflow/ui/rules/react.js
index 497110f2173..9f57ea30425 100644
--- a/airflow-core/src/airflow/ui/rules/react.js
+++ b/airflow-core/src/airflow/ui/rules/react.js
@@ -593,13 +593,6 @@ export const reactRules = /** @type {const} @satisfies 
{FlatConfig.Config} */ ({
      */
     [`${reactNamespace}/self-closing-comp`]: ERROR,
 
-    /**
-     * Enforce property declarations alphabetical sorting.
-     *
-     * @see 
[react/sort-prop-types](https://github.com/jsx-eslint/eslint-plugin-react/blob/HEAD/docs/rules/sort-prop-types.md)
-     */
-    [`${reactNamespace}/sort-prop-types`]: ERROR,
-
     /**
      * Disallow void DOM elements from receiving children.
      *
diff --git a/airflow-core/src/airflow/ui/rules/rem.js 
b/airflow-core/src/airflow/ui/rules/rem.js
deleted file mode 100644
index 75198a4e29c..00000000000
--- a/airflow-core/src/airflow/ui/rules/rem.js
+++ /dev/null
@@ -1,146 +0,0 @@
-/*!
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { AST_NODE_TYPES } from "@typescript-eslint/utils";
-
-export const remNamespace = "rem";
-
-/**
- * Check if a value contains rem units
- * @param {string} value
- * @returns {boolean}
- */
-const containsRem = (value) => /\d+\.?\d*rem/u.test(value);
-
-/**
- * Convert rem value to pixels (1rem = 16px)
- * @param {string} value
- * @returns {string}
- */
-const convertRemToPixels = (value) =>
-  value.replaceAll(/(?<temp1>\d+\.?\d*)rem/gu, (_, number) => {
-    if (typeof number === "string") {
-      const pixels = parseFloat(number) * 16;
-
-      return `${pixels}px`;
-    }
-
-    return value;
-  });
-
-export const remPlugin = {
-  rules: {
-    "no-rem-in-props": {
-      /** @param 
{import('@typescript-eslint/utils').TSESLint.RuleContext<'noRemInProps', []>} 
context */
-      create(context) {
-        /** @param 
{import('@typescript-eslint/utils').TSESTree.JSXOpeningElement} node */
-        const checkAttributes = (node) => {
-          // Check all attributes for rem values in size, width, height props 
(but not style)
-          node.attributes.forEach((attr) => {
-            if (attr.type !== AST_NODE_TYPES.JSXAttribute || !attr.value) {
-              return;
-            }
-
-            const attrName = attr.name.name;
-
-            // Skip style attributes - rem is allowed there
-            if (attrName === "style") {
-              return;
-            }
-
-            // Only check size, width, height attributes
-            if (attrName !== "height" && attrName !== "size" && attrName !== 
"width") {
-              return;
-            }
-
-            let attrValue = undefined;
-
-            // Handle different attribute value types
-            if (attr.value.type === AST_NODE_TYPES.Literal) {
-              attrValue = attr.value.value;
-            } else if (
-              attr.value.type === AST_NODE_TYPES.JSXExpressionContainer &&
-              attr.value.expression.type === AST_NODE_TYPES.Literal
-            ) {
-              attrValue = attr.value.expression.value;
-            }
-
-            // Check for rem values
-            if (typeof attrValue === "string" && containsRem(attrValue)) {
-              const fixedValue = convertRemToPixels(attrValue);
-
-              context.report({
-                data: {
-                  attribute: attrName,
-                  fixedValue,
-                  value: attrValue,
-                },
-                fix(fixer) {
-                  // For string literals, replace the entire value
-                  if (attr.value !== null && attr.value.type === 
AST_NODE_TYPES.Literal) {
-                    return fixer.replaceText(attr.value, `{${fixedValue}}`);
-                  }
-
-                  // For JSX expressions with literal values, replace just the 
literal
-                  return attr.value !== null &&
-                    attr.value.type === AST_NODE_TYPES.JSXExpressionContainer 
&&
-                    attr.value.expression.type === AST_NODE_TYPES.Literal
-                    ? fixer.replaceText(attr.value.expression, fixedValue)
-                    : null;
-                },
-                messageId: "noRemInProps",
-                node: attr,
-              });
-            }
-          });
-        };
-
-        return {
-          /** @param 
{import('@typescript-eslint/utils').TSESTree.JSXOpeningElement} node */
-          JSXOpeningElement(node) {
-            checkAttributes(node);
-          },
-        };
-      },
-      meta: {
-        docs: {
-          category: "Best Practices",
-          description: "Disallow rem units in size, width, and height 
attributes (but allow in style)",
-          recommended: "error",
-        },
-        fixable: "code",
-        messages: {
-          noRemInProps:
-            "Avoid using rem units in {{attribute}} attribute. Use numeric 
pixel values instead of '{{value}}'. Auto-fix available: {{fixedValue}}",
-        },
-        type: "problem",
-      },
-    },
-  },
-};
-
-/** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */
-export const remRules = {
-  files: ["**/*.tsx"],
-  plugins: {
-    [remNamespace]: remPlugin,
-  },
-  rules: {
-    [`${remNamespace}/no-rem-in-props`]: "error",
-  },
-};
diff --git a/airflow-core/src/airflow/ui/rules/typescript.js 
b/airflow-core/src/airflow/ui/rules/typescript.js
index a09d1e7b9b7..55d7404072b 100644
--- a/airflow-core/src/airflow/ui/rules/typescript.js
+++ b/airflow-core/src/airflow/ui/rules/typescript.js
@@ -22,8 +22,7 @@
 import typescript from "@typescript-eslint/eslint-plugin";
 import typescriptParser from "@typescript-eslint/parser";
 
-import { ERROR, OFF, WARN } from "./levels.js";
-import { off } from "./off.js";
+import { ERROR, WARN } from "./levels.js";
 
 /**
  * ESLint TypeScript namespace.
@@ -393,21 +392,6 @@ export const typescriptRules = /** @type {const} 
@satisfies {FlatConfig.Config}
      */
     [`${typescriptNamespace}/dot-notation`]: ERROR,
 
-    /**
-     * Rely on inference instead of making return type explicit.
-     *
-     * @example
-     * ```typescript
-     * // ❌ Incorrect
-     * const foo = (): string => "bar";
-     *
-     * // ✅ Correct
-     * const foo = () => "bar";
-     * ```
-     * @see 
[@typescript-eslint/explicit-function-return-type](https://typescript-eslint.io/rules/explicit-function-return-type/)
-     */
-    [`${typescriptNamespace}/explicit-function-return-type`]: OFF,
-
     /**
      * When working with classes, let's be explicit about accessibility.
      *
@@ -435,30 +419,6 @@ export const typescriptRules = /** @type {const} 
@satisfies {FlatConfig.Config}
       },
     ],
 
-    /**
-     * Rely on inference for boundary types as well.
-     *
-     * @example
-     * ```typescript
-     * // ❌ Incorrect
-     * const foo = (): string => "bar";
-     *
-     * // ✅ Correct
-     * const foo = () => "bar";
-     * ```
-     * @see 
[@typescript-eslint/explicit-module-boundary-types](https://typescript-eslint.io/rules/explicit-module-boundary-types/)
-     */
-    [`${typescriptNamespace}/explicit-module-boundary-types`]: OFF,
-
-    /**
-     * We allow declaring variables without values in favor of 
`no-useless-assignment`
-     *
-     * @see 
[@typescript-eslint/init-declarations](https://typescript-eslint.io/rules/init-declarations/)
-     * @see 
[init-declarations](https://eslint.org/docs/latest/rules/init-declarations)
-     * @see 
[no-useless-assignment](https://eslint.org/docs/latest/rules/no-useless-assignment)
-     */
-    [`${typescriptNamespace}/init-declarations`]: OFF,
-
     /**
      * Max amount of parameters set to 3. More than that is too much.
      *
@@ -763,13 +723,6 @@ export const typescriptRules = /** @type {const} 
@satisfies {FlatConfig.Config}
      */
     [`${typescriptNamespace}/no-import-type-side-effects`]: ERROR,
 
-    /**
-     * We want to rely on inference.
-     *
-     * @see 
[@typescript-eslint/no-inferrable-types](https://typescript-eslint.io/rules/no-inferrable-types/)
-     */
-    [`${typescriptNamespace}/no-inferrable-types`]: OFF,
-
     /**
      * Avoid using `this` outside a class.
      *
@@ -1001,6 +954,39 @@ export const typescriptRules = /** @type {const} 
@satisfies {FlatConfig.Config}
       },
     ],
 
+    /**
+     * Do not type components with `FC` (or `FunctionComponent`, `VFC`).
+     * Annotate the props parameter instead, and use `ComponentType` for a
+     * component held in a value.
+     *
+     * @example
+     * ```typescript
+     * // ❌ Incorrect
+     * const Foo: React.FC<FooProps> = ({ bar }) => <div>{bar}</div>;
+     *
+     * // ✅ Correct
+     * const Foo = ({ bar }: FooProps) => <div>{bar}</div>;
+     * ```
+     * @see 
[@typescript-eslint/no-restricted-types](https://typescript-eslint.io/rules/no-restricted-types/)
+     */
+    [`${typescriptNamespace}/no-restricted-types`]: [
+      ERROR,
+      {
+        types: {
+          FC: "Do not type components with `FC`. Annotate the props parameter 
instead: `const Foo = ({ bar }: FooProps) => ...`, or use `ComponentType` for a 
component value.",
+          FunctionComponent:
+            "Do not type components with `FunctionComponent`. Annotate the 
props parameter instead: `const Foo = ({ bar }: FooProps) => ...`, or use 
`ComponentType` for a component value.",
+          "React.FC":
+            "Do not type components with `React.FC`. Annotate the props 
parameter instead: `const Foo = ({ bar }: FooProps) => ...`, or use 
`ComponentType` for a component value.",
+          "React.FunctionComponent":
+            "Do not type components with `React.FunctionComponent`. Annotate 
the props parameter instead: `const Foo = ({ bar }: FooProps) => ...`, or use 
`ComponentType` for a component value.",
+          "React.VFC":
+            "Do not type components with `React.VFC`. Annotate the props 
parameter instead: `const Foo = ({ bar }: FooProps) => ...`, or use 
`ComponentType` for a component value.",
+          VFC: "Do not type components with `VFC`. Annotate the props 
parameter instead: `const Foo = ({ bar }: FooProps) => ...`, or use 
`ComponentType` for a component value.",
+        },
+      },
+    ],
+
     /**
      * Avoid name shadowing (`_` is allowed).
      *
@@ -1825,73 +1811,5 @@ export const typescriptRules = /** @type {const} 
@satisfies {FlatConfig.Config}
      * @see 
[@typescript-eslint/use-unknown-in-catch-callback-variable](https://typescript-eslint.io/rules/use-unknown-in-catch-callback-variable/)
      */
     [`${typescriptNamespace}/use-unknown-in-catch-callback-variable`]: ERROR,
-
-    // Rules covered by `@typescript-eslint` or by the language itself.
-    ...off(
-      "camelcase",
-      "class-methods-use-this",
-      "consistent-return",
-      "constructor-super",
-      "default-param-last",
-      "dot-notation",
-      "getter-return",
-      "id-match",
-      "init-declarations",
-      "max-params",
-      "new-cap",
-      "no-array-constructor",
-      "no-async-promise-executor",
-      "no-case-declarations",
-      "no-class-assign",
-      "no-cond-assign",
-      "no-const-assign",
-      "no-constant-binary-expression",
-      "no-constant-condition",
-      "no-delete-var",
-      "no-dupe-args",
-      "no-dupe-class-members",
-      "no-dupe-else-if",
-      "no-dupe-keys",
-      "no-extra-boolean-cast",
-      "no-fallthrough",
-      "no-func-assign",
-      "no-global-assign",
-      "no-implicit-globals",
-      "no-implied-eval",
-      "no-invalid-this",
-      "no-loop-func",
-      "no-nonoctal-decimal-escape",
-      "no-obj-calls",
-      "no-octal-escape",
-      "no-promise-executor-return",
-      "no-redeclare",
-      "no-restricted-imports",
-      "no-setter-return",
-      "no-shadow",
-      "no-this-before-super",
-      "no-throw-literal",
-      "no-undef-init",
-      "no-undef",
-      "no-undefined",
-      "no-underscore-dangle",
-      "no-unreachable",
-      "no-unsafe-negation",
-      "no-unsafe-optional-chaining",
-      "no-unused-expressions",
-      "no-unused-labels",
-      "no-unused-private-class-members",
-      "no-unused-vars",
-      "no-use-before-define",
-      "no-useless-backreference",
-      "no-useless-constructor",
-      "no-with",
-      "prefer-destructuring",
-      "require-await",
-      "use-isnan",
-      "valid-typeof",
-      `${typescriptNamespace}/no-dupe-class-members`,
-      `${typescriptNamespace}/no-redeclare`,
-      `${typescriptNamespace}/no-unused-vars`,
-    ),
   },
 });
diff --git a/airflow-core/src/airflow/ui/rules/unicorn.js 
b/airflow-core/src/airflow/ui/rules/unicorn.js
index 304943f3178..a3bdd7e1ba1 100644
--- a/airflow-core/src/airflow/ui/rules/unicorn.js
+++ b/airflow-core/src/airflow/ui/rules/unicorn.js
@@ -21,7 +21,7 @@
  */
 import unicorn from "eslint-plugin-unicorn";
 
-import { ERROR, OFF, WARN } from "./levels.js";
+import { ERROR, WARN } from "./levels.js";
 
 /**
  * ESLint `unicorn` namespace.
@@ -437,25 +437,6 @@ export const unicornRules = /** @type {const} @satisfies 
{FlatConfig.Config} */
      */
     [`${unicornNamespace}/no-new-buffer`]: ERROR,
 
-    /**
-     * Disallow the use of the null literal.
-     *
-     * @example
-     * ```typescript
-     * // ❌ Incorrect
-     * let foo = null;
-     * if (bar == null) {}
-     *
-     * // ✅ Correct
-     * let foo;
-     * const foo = Object.create(null);
-     * if (foo === null) {}
-     * ```
-     * @see [Reasoning](https://lou.cx/articles/we-don-t-need-null/)
-     * @see 
[unicorn/no-null](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/no-null.md)
-     */
-    [`${unicornNamespace}/no-null`]: OFF,
-
     /**
      * Disallow passing single-element arrays to Promise methods.
      *
diff --git 
a/airflow-core/src/airflow/ui/src/components/PluginActions/PluginReactAction.tsx
 
b/airflow-core/src/airflow/ui/src/components/PluginActions/PluginReactAction.tsx
index 5de1abf4fdd..8d1776d0149 100644
--- 
a/airflow-core/src/airflow/ui/src/components/PluginActions/PluginReactAction.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/PluginActions/PluginReactAction.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { type FC, useRef, useState } from "react";
+import { type ComponentType, useRef, useState } from "react";
 
 import type { PluginReactActionResponse } from "openapi/requests/types.gen";
 
@@ -55,7 +55,7 @@ export const PluginReactAction = ({
   readonly pluginProps: PluginProps;
 }) => {
   const [open, setOpen] = useState(false);
-  const [pluginComponent, setPluginComponent] = 
useState<FC<PluginActionProps>>();
+  const [pluginComponent, setPluginComponent] = 
useState<ComponentType<PluginActionProps>>();
   const [isLoading, setIsLoading] = useState(false);
   const triggerRef = useRef<HTMLButtonElement>(null);
 
@@ -68,7 +68,7 @@ export const PluginReactAction = ({
       const { default: loaded } = await loadPlugin(action);
 
       // Wrapped in a setter: a bare component would be called as a lazy 
initializer.
-      setPluginComponent(() => loaded as FC<PluginActionProps>);
+      setPluginComponent(() => loaded as ComponentType<PluginActionProps>);
       setIsLoading(false);
     }
 
diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx 
b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
index 6a428e3c4d0..6e7f4037d4f 100644
--- a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { type FC, lazy, Suspense } from "react";
+import { type ComponentType, lazy, Suspense } from "react";
 
 import { Spinner } from "@chakra-ui/react";
 import { useParams } from "react-router-dom";
@@ -51,7 +51,7 @@ export type PluginProps = {
   taskInstance?: TaskInstanceResponse;
 };
 
-type PluginComponentType = FC<PluginProps>;
+type PluginComponentType = ComponentType<PluginProps>;
 
 const loadPluginDependencies = async () => {
   const { registerPluginDependencies } = await 
import("src/utils/registerPluginDependencies");
diff --git a/airflow-core/src/airflow/ui/tsconfig.dev.json 
b/airflow-core/src/airflow/ui/tsconfig.dev.json
index def547860e8..b671f53b175 100644
--- a/airflow-core/src/airflow/ui/tsconfig.dev.json
+++ b/airflow-core/src/airflow/ui/tsconfig.dev.json
@@ -8,5 +8,5 @@
     "strict": true,
     "target": "ESNext"
   },
-  "include": ["./*.ts", "./*.js", "./rules/*.js", "./rules/*.ts", 
"./tests/**/*.ts"]
+  "include": ["./*.ts", "./*.js", "./rules/**/*.js", "./rules/**/*.ts", 
"./tests/**/*.ts"]
 }
diff --git a/dev/breeze/src/airflow_breeze/commands/ui_commands.py 
b/dev/breeze/src/airflow_breeze/commands/ui_commands.py
index 712a9eb882e..af7c5e349fc 100644
--- a/dev/breeze/src/airflow_breeze/commands/ui_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ui_commands.py
@@ -62,9 +62,10 @@ def _get_char_code(char: str) -> int:
 
 
 def natural_sort_key(text: str) -> tuple:
-    """Create a sort key matching eslint-plugin-jsonc with natural: true.
+    """Create a sort key matching the ``natural-compare`` npm package.
 
-    natural-compare, the comparator behind the UI's ``jsonc/sort-keys`` rule, 
compares
+    This is the locale key order the ``check-i18n-json`` prek hook enforces 
(keep the two
+    in sync), originally from the UI's ``jsonc/sort-keys`` rule. 
natural-compare compares
     digit runs numerically and other characters by ``_get_char_code``. Each 
token also
     carries a bucket so numbers sort between characters below "0" and above 
"9".
     """
@@ -80,7 +81,7 @@ def natural_sort_key(text: str) -> tuple:
         else:
             for char in part:
                 code = _get_char_code(char)
-                tokens.append((0, code) if code < 66 else (2, code))
+                tokens.append((0, code) if code < _CHAR_CODES["0"] else (2, 
code))
     return tuple(tokens)
 
 
diff --git a/scripts/ci/prek/check_i18n_json.py 
b/scripts/ci/prek/check_i18n_json.py
index 38abb046ffe..9848eb42d27 100755
--- a/scripts/ci/prek/check_i18n_json.py
+++ b/scripts/ci/prek/check_i18n_json.py
@@ -24,20 +24,77 @@
 """
 Prek hook to check that all .json files in 
airflow-core/src/airflow/ui/public/i18n/locales/
 are valid JSON and do not contain any 'TODO:' entries.
-Also formats JSON files automatically.
+Also formats JSON files automatically and sorts their keys.
 """
 
 from __future__ import annotations
 
 import json
+import re
 import sys
 from pathlib import Path
+from typing import Any
 
 from common_prek_utils import AIRFLOW_ROOT_PATH, console
 
 LOCALES_DIR = AIRFLOW_ROOT_PATH / "airflow-core" / "src" / "airflow" / "ui" / 
"public" / "i18n" / "locales"
 
 
+# natural-compare's sort order of ASCII: NUL-"," keep their ASCII order, then 
"." "/",
+# the remaining punctuation, "-" (just before digits), digits, uppercase, 
lowercase.
+# A character's position in this string is its natural-compare sort code.
+_NATURAL_CHAR_ORDER = (
+    "".join(chr(code) for code in range(32))  # control characters
+    + " !\"#$%&'()*+,"
+    + "./"
+    + ":;<=>?@"
+    + "[\\]^_`"
+    + "{|}~\x7f"
+    + "-"
+    + "0123456789"
+    + "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+    + "abcdefghijklmnopqrstuvwxyz"
+)
+_CHAR_CODES = {char: code for code, char in enumerate(_NATURAL_CHAR_ORDER)}
+
+
+def _get_char_code(char: str) -> int:
+    """Return natural-compare's sort code for a character; non-ASCII by code 
point."""
+    return _CHAR_CODES.get(char, ord(char))
+
+
+def natural_sort_key(text: str) -> tuple:
+    """Create a sort key matching the ``natural-compare`` npm package.
+
+    This is the order the UI's former ``jsonc/sort-keys`` ESLint rule 
(``natural: true``) enforced,
+    and the same key ``breeze ui`` sorts locale files with (keep the two in 
sync). natural-compare compares
+    digit runs numerically and other characters by ``_get_char_code``. Each 
token also
+    carries a bucket so numbers sort between characters below "0" and above 
"9".
+    """
+    tokens: list[tuple] = []
+    for part in re.split(r"([0-9]+)", text):
+        if part.isascii() and part.isdigit():
+            digits = part.lstrip("0")
+            # natural-compare treats a leading "0" as a plain character and 
only the
+            # rest of the run as a number.
+            tokens.extend([(0, _get_char_code("0"))] * (len(part) - 
len(digits)))
+            if digits:
+                tokens.append((1, int(digits)))
+        else:
+            for char in part:
+                code = _get_char_code(char)
+                tokens.append((0, code) if code < _CHAR_CODES["0"] else (2, 
code))
+    return tuple(tokens)
+
+
+def sort_keys_naturally(value: Any) -> Any:
+    if isinstance(value, dict):
+        return {key: sort_keys_naturally(value[key]) for key in sorted(value, 
key=natural_sort_key)}
+    if isinstance(value, list):
+        return [sort_keys_naturally(item) for item in value]
+    return value
+
+
 def main():
     failed = False
     for json_file in LOCALES_DIR.rglob("*.json"):
@@ -49,10 +106,8 @@ def main():
                 console.print(f"[bold red][FAIL][/bold red] 'TODO:' found in 
[yellow]{rel_path}[/yellow]")
                 failed = True
             try:
-                parsed_json = json.loads(content)
-                formatted_content = (
-                    json.dumps(parsed_json, indent=2, ensure_ascii=False, 
sort_keys=False) + "\n"
-                )
+                parsed_json = sort_keys_naturally(json.loads(content))
+                formatted_content = json.dumps(parsed_json, indent=2, 
ensure_ascii=False) + "\n"
                 if content != formatted_content:
                     json_file.write_text(formatted_content, encoding="utf-8")
                     console.print(f"[bold green][FORMATTED][/bold green] 
[yellow]{rel_path}[/yellow]")
diff --git a/scripts/ci/prek/ts_compile_lint_ui.py 
b/scripts/ci/prek/ts_compile_lint_ui.py
index 7786b041919..96227b33f3d 100755
--- a/scripts/ci/prek/ts_compile_lint_ui.py
+++ b/scripts/ci/prek/ts_compile_lint_ui.py
@@ -47,6 +47,7 @@ if __name__ == "__main__":
     ]
     all_non_yaml_files = [file for file in files if not file.endswith(".yaml")]
     print("All non-YAML files:", all_non_yaml_files)
+    all_eslint_files = [file for file in files if file.endswith((".js", ".ts", 
".tsx"))]
     all_ts_files = [file for file in files if file.endswith(".ts") or 
file.endswith(".tsx")]
     if all_ts_files:
         all_ts_files.append("src/vite-env.d.ts")
@@ -80,7 +81,8 @@ if __name__ == "__main__":
     if any("/openapi/" in file for file in original_files):
         run_command(["pnpm", "codegen"], cwd=dir)
     if all_non_yaml_files:
-        run_command(["pnpm", "eslint", "--fix", *all_non_yaml_files], cwd=dir)
+        if all_eslint_files:
+            run_command(["pnpm", "eslint", "--fix", *all_eslint_files], 
cwd=dir)
         run_command(
             ["pnpm", "oxfmt", "--write", "--no-error-on-unmatched-pattern", 
*all_non_yaml_files], cwd=dir
         )
diff --git a/scripts/tests/ci/prek/test_check_i18n_json.py 
b/scripts/tests/ci/prek/test_check_i18n_json.py
new file mode 100644
index 00000000000..6cf555cd13e
--- /dev/null
+++ b/scripts/tests/ci/prek/test_check_i18n_json.py
@@ -0,0 +1,98 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import ast
+import json
+from pathlib import Path
+
+import pytest
+from ci.prek import check_i18n_json
+from ci.prek.check_i18n_json import natural_sort_key, sort_keys_naturally
+from ci.prek.common_prek_utils import AIRFLOW_ROOT_PATH
+
+BREEZE_UI_COMMANDS_PATH = (
+    AIRFLOW_ROOT_PATH / "dev" / "breeze" / "src" / "airflow_breeze" / 
"commands" / "ui_commands.py"
+)
+NATURAL_SORT_NAMES = ("_NATURAL_CHAR_ORDER", "_get_char_code", 
"natural_sort_key")
+
+
+def get_natural_sort_definitions(source: str) -> dict[str, str]:
+    definitions = {}
+    for node in ast.parse(source).body:
+        if isinstance(node, ast.FunctionDef) and node.name in 
NATURAL_SORT_NAMES:
+            if ast.get_docstring(node) is not None:
+                node.body = node.body[1:]
+            definitions[node.name] = ast.dump(node)
+        elif isinstance(node, ast.Assign):
+            for target in node.targets:
+                if isinstance(target, ast.Name) and target.id in 
NATURAL_SORT_NAMES:
+                    definitions[target.id] = ast.dump(node)
+    return definitions
+
+
+class TestNaturalSortKey:
+    # Pairs in the order the `natural-compare` npm package that 
`jsonc/sort-keys` used puts them.
+    @pytest.mark.parametrize(
+        ("first", "second"),
+        [
+            ("B", "a"),
+            ("a", "ab"),
+            ("a2", "a10"),
+            ("x9y", "x10y"),
+            ("a01", "a1"),
+            ("a0", "a1"),
+            ("item010", "item2x"),
+            ("a b", "a.b"),
+            ("a.b", "a-b"),
+            ("a.b", "a_b"),
+            ("a_b", "ab"),
+            ("dag_run", "dagRun"),
+            ("_x", "Z9"),
+            ("~a", "z9"),
+        ],
+    )
+    def test_orders_like_natural_compare(self, first, second):
+        assert natural_sort_key(first) < natural_sort_key(second)
+
+
+class TestSortKeysNaturally:
+    def test_sorts_nested_objects_and_objects_in_arrays(self):
+        value = {
+            "item10": 1,
+            "item2": {"b": 1, "a": 2},
+            "Item": [{"z": 1, "y": 2}, "keep"],
+        }
+
+        result = sort_keys_naturally(value)
+
+        assert json.dumps(result) == json.dumps(
+            {
+                "Item": [{"y": 2, "z": 1}, "keep"],
+                "item2": {"a": 2, "b": 1},
+                "item10": 1,
+            }
+        )
+
+
+def test_natural_sort_key_matches_breeze_copy():
+    """`breeze ui` sorts locale files with its own copy; the two must order 
keys identically."""
+    hook_definitions = 
get_natural_sort_definitions(Path(check_i18n_json.__file__).read_text())
+    breeze_definitions = 
get_natural_sort_definitions(BREEZE_UI_COMMANDS_PATH.read_text())
+
+    assert set(hook_definitions) == set(NATURAL_SORT_NAMES)
+    assert hook_definitions == breeze_definitions

Reply via email to