From 985874506e82625838b2346ef49a8325a55e10f5 Mon Sep 17 00:00:00 2001
From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 10:24:00 +0800
Subject: [PATCH] ci: add observational ESLint check (#550)
---
.github/workflows/test.yml | 20 +++++++++
docs/code-quality.md | 10 +++--
docs/testing.md | 2 +-
eslint.config.js | 30 ++++++++++---
tests/config/eslint-config.spec.ts | 70 ++++++++++++++++++++++++++++++
5 files changed, 122 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3ca0b354..1403d842 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,6 +13,26 @@ concurrency:
cancel-in-progress: true
jobs:
+ lint:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Setup Node
+ uses: actions/setup-node@v7
+ with:
+ node-version: '24'
+ cache: yarn
+ cache-dependency-path: yarn.lock
+
+ - name: Install dependencies
+ run: yarn --frozen-lockfile
+
+ - name: Lint
+ run: yarn lint
+
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
diff --git a/docs/code-quality.md b/docs/code-quality.md
index 238e3526..e002216b 100644
--- a/docs/code-quality.md
+++ b/docs/code-quality.md
@@ -77,11 +77,13 @@ yarn build
## ESLint CI:先观察,再强制
-ESLint 本地检查稳定后,Pull Request workflow 增加全仓 `yarn lint`:
+第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job:
-1. 初始阶段作为普通 check 运行,不立即配置 required check。
-2. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
-3. 连续多个 PR 稳定通过后,再由维护者决定是否设为 required。
+1. lint 与既有 `unit-tests` 使用不同 job,避免改变现有测试 check 的名称和职责。
+2. 初始阶段作为普通 check 运行,不立即配置 required check。
+3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
+4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
+5. 连续多个 PR 稳定通过后,再由维护者决定是否设为 required。
ESLint CI 必须检查全仓受管源码,而不是只检查 PR 变更文件。已有问题通过规则选择或受控基线管理,保证新问题不能借由“只检查改动行”绕过项目约束。
diff --git a/docs/testing.md b/docs/testing.md
index 5dbff101..bc6e12ad 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -92,4 +92,4 @@ yarn lint
yarn build
```
-Pull Request 测试工作流使用 Node 24 LTS 和 frozen lockfile,依次执行类型检查和覆盖率门禁。ESLint、Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)渐进接入,新增测试代码不得引入新的 lint 或格式问题。
+Pull Request 工作流使用 Node 24 LTS 和 frozen lockfile。`unit-tests` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查,当前处于普通 check 观察阶段,不改变既有 required checks。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
diff --git a/eslint.config.js b/eslint.config.js
index 0d7a0415..f197665a 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -10,18 +10,20 @@ const javascriptFiles = [
]
const typescriptFiles = [
- '**/*.{ts,tsx}',
+ '**/*.{ts,tsx,mts,cts}',
'**/*.vue',
]
+const managedScriptExtensions = 'js,mjs,cjs,jsx,ts,tsx,mts,cts'
+
const managedFiles = [
...javascriptFiles,
...typescriptFiles,
]
const browserFiles = [
- 'src/**/*.{js,jsx,ts,tsx,vue}',
- 'examples/**/src/**/*.{js,jsx,ts,tsx,vue}',
+ `src/**/*.{${managedScriptExtensions},vue}`,
+ `examples/**/src/**/*.{${managedScriptExtensions},vue}`,
]
const nodeFiles = [
@@ -30,6 +32,16 @@ const nodeFiles = [
'scripts/**/*.{js,mjs,cjs,ts,mts,cts}',
]
+// TypeScript 关闭核心 no-undef;显式禁止浏览器域中的 Node-only globals,保证 JS、TS 与 Vue 使用同一运行时边界。
+const browserGlobalNames = new Set(Object.keys(globals.browser))
+const nodeOnlyGlobalRestrictions = Object.keys(globals.node)
+ .filter(name => !browserGlobalNames.has(name))
+ .sort()
+ .map(name => ({
+ name,
+ message: `'${name}' is only available in Node.js code.`,
+ }))
+
const restrictedCoreSourcePattern = '/(?:^|\\/)@core(?:\\/|$)/'
// @layouts 是 @core 的下游依赖;禁止反向引用,避免两层形成直接循环。
@@ -95,6 +107,7 @@ export default defineConfig([
'**/dist/**',
'**/coverage/**',
'**/.worktrees/**',
+ '**/vite.config.*.timestamp-*.mjs',
'public/plugin_icon/**',
'src/@iconify/**',
'**/*.d.ts',
@@ -136,6 +149,9 @@ export default defineConfig([
languageOptions: {
globals: globals.browser,
},
+ rules: {
+ 'no-restricted-globals': ['error', ...nodeOnlyGlobalRestrictions],
+ },
},
{
name: 'moviepilot/node-globals',
@@ -143,6 +159,10 @@ export default defineConfig([
languageOptions: {
globals: globals.node,
},
+ // Node 配置可能位于浏览器源码目录;显式覆盖浏览器专用限制,避免 flat config 合并后误报。
+ rules: {
+ 'no-restricted-globals': 'off',
+ },
},
{
name: 'moviepilot/sonarjs',
@@ -154,7 +174,7 @@ export default defineConfig([
},
{
name: 'moviepilot/sonarjs-locales',
- files: ['src/locales/**/*.{js,jsx,ts,tsx}'],
+ files: [`src/locales/**/*.{${managedScriptExtensions}}`],
rules: {
'sonarjs/no-hardcoded-passwords': 'off',
'sonarjs/no-hardcoded-secrets': 'off',
@@ -169,7 +189,7 @@ export default defineConfig([
},
{
name: 'moviepilot/layout-boundary',
- files: ['src/@layouts/**/*.{js,jsx,ts,tsx,vue}'],
+ files: [`src/@layouts/**/*.{${managedScriptExtensions},vue}`],
rules: {
'no-restricted-syntax': [
'error',
diff --git a/tests/config/eslint-config.spec.ts b/tests/config/eslint-config.spec.ts
index 97a467a9..31fbf010 100644
--- a/tests/config/eslint-config.spec.ts
+++ b/tests/config/eslint-config.spec.ts
@@ -1,9 +1,15 @@
import { ESLint } from 'eslint'
+import globals from 'globals'
import { expect, it } from 'vitest'
const eslint = new ESLint()
const restrictedSyntaxRule = 'no-restricted-syntax'
+const restrictedGlobalsRule = 'no-restricted-globals'
const unusedVariablesRule = '@typescript-eslint/no-unused-vars'
+const browserGlobalNames = new Set(Object.keys(globals.browser))
+const nodeOnlyGlobalNames = Object.keys(globals.node)
+ .filter(name => !browserGlobalNames.has(name))
+ .sort()
/** 使用真实 flat config 检查虚拟源码,避免项目边界和新增问题门禁在依赖升级时静默丢失。 */
async function lintRuleIds(code: string, filePath: string) {
@@ -160,6 +166,13 @@ const cases = [
ruleId: 'no-undef',
shouldReport: true,
},
+ {
+ name: '拒绝浏览器 Vue 脚本使用 Node 全局变量',
+ code: '{{ bytes }}',
+ filePath: 'src/components/invalid-node-global.vue',
+ ruleId: restrictedGlobalsRule,
+ shouldReport: true,
+ },
{
name: '允许构建配置使用 Node 全局变量',
code: 'process.env.NODE_ENV',
@@ -167,6 +180,34 @@ const cases = [
ruleId: 'no-undef',
shouldReport: false,
},
+ {
+ name: '允许 TypeScript 构建配置使用 Node 全局变量',
+ code: 'const runtime: string | undefined = process.env.NODE_ENV; console.log(runtime)',
+ filePath: 'example.config.mts',
+ ruleId: restrictedGlobalsRule,
+ shouldReport: false,
+ },
+ {
+ name: '允许浏览器目录中的 Node 配置使用 Node 全局变量',
+ code: 'const runtime: string | undefined = process.env.NODE_ENV; console.log(runtime)',
+ filePath: 'src/tool.config.ts',
+ ruleId: restrictedGlobalsRule,
+ shouldReport: false,
+ },
+ {
+ name: '拒绝 MTS 脚本保留 debugger',
+ code: 'const value: string = "x"; console.log(value); debugger',
+ filePath: 'scripts/invalid-debugger.mts',
+ ruleId: 'no-debugger',
+ shouldReport: true,
+ },
+ {
+ name: '拒绝 CTS 脚本保留 debugger',
+ code: 'const value: string = "x"; console.log(value); debugger',
+ filePath: 'scripts/invalid-debugger.cts',
+ ruleId: 'no-debugger',
+ shouldReport: true,
+ },
{
name: '允许 Vuetify 点号 slot 名称',
code: '',
@@ -195,3 +236,32 @@ it.each(cases)('$name', async ({ code, filePath, ruleId, shouldReport }) => {
expect(ruleIds.includes(ruleId)).toBe(shouldReport)
})
+
+it.each(nodeOnlyGlobalNames)('拒绝浏览器 TypeScript 使用 Node 全局变量 %s', async globalName => {
+ const ruleIds = await lintRuleIds(`console.log(${globalName})`, `src/components/invalid-${globalName}.ts`)
+
+ expect(ruleIds).toContain(restrictedGlobalsRule)
+})
+
+it.each([
+ 'example.config.mts',
+ 'example.config.cts',
+ 'scripts/example.mts',
+ 'scripts/example.cts',
+])('使用 TypeScript parser 解析 %s', async filePath => {
+ const ruleIds = await lintRuleIds('const value: string = "x"; console.log(value)', filePath)
+
+ expect(ruleIds).not.toContain(null)
+})
+
+it('忽略 Vite 生成的临时配置模块', async () => {
+ await expect(eslint.isPathIgnored('vite.config.ts.timestamp-1784340502076-0129cef6d3bbd8.mjs')).resolves.toBe(true)
+})
+
+it('不忽略普通 MJS 源码', async () => {
+ await expect(eslint.isPathIgnored('scripts/example.mjs')).resolves.toBe(false)
+})
+
+it('不忽略名称包含 timestamp 的普通 MJS 源码', async () => {
+ await expect(eslint.isPathIgnored('scripts/feature.timestamp-parser.mjs')).resolves.toBe(false)
+})