ci: add observational ESLint check (#550)

This commit is contained in:
InfinityPacer
2026-07-18 10:24:00 +08:00
committed by GitHub
parent 8434b75af3
commit 985874506e
5 changed files with 122 additions and 10 deletions

View File

@@ -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

View File

@@ -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 变更文件。已有问题通过规则选择或受控基线管理,保证新问题不能借由“只检查改动行”绕过项目约束。

View File

@@ -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 或格式问题。

View File

@@ -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',

View File

@@ -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: '<script setup lang="ts">const bytes = Buffer.from("x")</script><template>{{ bytes }}</template>',
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: '<template><VDataTable><template #item.name /></VDataTable></template>',
@@ -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)
})