chore: migrate frontend linting to ESLint 10 (#549)

This commit is contained in:
InfinityPacer
2026-07-18 09:42:34 +08:00
committed by GitHub
parent 8a1c10bbf7
commit 8434b75af3
12 changed files with 2147 additions and 846 deletions
-69
View File
@@ -1,69 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: ['@antfu/eslint-config-vue', 'plugin:sonarjs/recommended'],
ignorePatterns: ['src/@iconify/*.js', 'node_modules', 'dist', '*.d.ts'],
plugins: ['regex'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'sonarjs/no-duplicate-string': 'warn',
'vue/valid-v-slot': ['error', {
allowModifiers: true,
}],
// https://github.com/gmullerb/eslint-plugin-regex
'regex/invalid': [
'error',
[
{
regex: '@/assets/images',
replacement: '@images',
message: 'Use \'@images\' path alias for image imports',
},
{
regex: '@/styles',
replacement: '@styles',
message: 'Use \'@styles\' path alias for importing styles from \'src/styles\'',
},
// {
// id: 'Disallow icon of icon library',
// regex: 'tabler-\\w',
// message: 'Only \'mdi\' icons are allowed',
// },
{
regex: '@core/\\w',
message: 'You can\'t use @core when you are in @layouts module',
files: {
inspect: '@layouts/.*',
},
},
{
regex: 'useLayouts\\(',
message:
'`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.',
files: {
inspect: '^(?!.*(@core|@layouts)).*',
},
},
],
// Ignore files
'.eslintrc.js',
],
},
settings: {
'import/resolver': {
node: {
extensions: ['.ts', '.js', '.tsx', '.jsx', '.mjs', '.png', '.jpg'],
},
typescript: {},
},
},
}
+1
View File
@@ -0,0 +1 @@
24
+1
View File
@@ -0,0 +1 @@
24
+3 -1
View File
@@ -2,7 +2,7 @@
*中文 | [English](README_EN.md)* *中文 | [English](README_EN.md)*
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目,NodeJS版本:>= `v20.12.1` [MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目,最低支持 Node.js `20.19`,推荐使用 Node.js `24`
## 特性 ## 特性
@@ -49,6 +49,8 @@ yarn test:coverage
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。 测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
ESLint、Prettier、Node 兼容范围及渐进式 CI 门禁见[前端代码质量工具链演进](docs/code-quality.md)。
### 静态运行 ### 静态运行
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件,nginx配置参考 `public/nginx.conf` 1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件,nginx配置参考 `public/nginx.conf`
+164
View File
@@ -0,0 +1,164 @@
# 前端代码质量工具链演进
本文档定义 MoviePilot-Frontend 的代码质量工具链、运行入口和渐进式演进路线。目标是让人工开发者和自动化 Agent 都通过同一组项目命令完成格式化、静态检查、类型检查、测试和构建,并由 CI 在干净环境中复现结果;编辑器及其扩展只提供即时反馈,不作为项目行为的事实源。
## 工具职责
| 工具 | 职责 | 不负责 |
| --- | --- | --- |
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
| Vitest | 单元测试、组件测试和覆盖率门槛 | 生产构建与真实浏览器行为 |
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
ESLint 配置由仓库显式维护,不继承 Antfu 等覆盖面较大的个人风格预设。JavaScript、TypeScript 与 Vue 使用各自面向正确性的 recommended/essential 基线;SonarJS 不整包展开 recommended,而只显式启用安全和正确性的高信号规则,避免插件升级隐式扩大检查范围。
历史规则迁移前必须核对当前源码和运行职责,不能仅因为旧配置出现过就写入 baseline。当前保留 Vuetify 点号 slot 兼容、`@layouts` 不反向依赖 `@core`、浏览器与 Node globals 分域及统一禁止 `debugger`。图片/样式 alias 强制只约束 import 拼写且已与当前实践脱节;`useLayouts` 目录限制所针对的 API 从未存在于源码;重复字符串属于高噪声风格治理;伪随机数限制会把界面效果误判为安全问题,因此这四类历史规则不再作为门禁。
Prettier 独立运行,ESLint 中与格式重叠的规则保持关闭。任何编辑器都可以调用项目内 ESLint 和 Prettier,但保存时是否自动执行不影响 CI 结果。
## Node 与依赖边界
- 最低兼容目标为 Node.js 20.19Node.js 20.19、22 和 24 的开发环境可以安装依赖并运行项目命令。
- 推荐开发环境和 CI 主环境使用 Node.js 24。
- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build;完整覆盖率门禁在 Node.js 24 执行。
- Yarn 1 与 `yarn.lock` 继续作为依赖安装事实源,CI 使用 `yarn --frozen-lockfile`
- Node 只用于依赖安装、开发、测试和前端构建;MoviePilot 正式 Docker 镜像使用预构建前端产物,不因开发工具链升级而增加 Node 运行时。
最低 Node 版本变化属于开发环境兼容性变更,必须独立说明并在 CI 验证。不得仅提高 `package.json``engines.node` 而不更新 README、版本文件和 workflow。
## 统一命令
工具链完成演进后,仓库提供以下入口:
```sh
yarn lint # 只读 ESLint 检查,不修改文件
yarn lint:fix # 显式执行 ESLint 安全修复
yarn lint:suppressions:prune # 修复存量问题后裁剪过期 baseline
yarn format # 使用 Prettier 格式化目标文件
yarn format:check # 检查目标文件格式,不修改文件
yarn typecheck
yarn test:run
yarn test:coverage
yarn build
```
`yarn lint` 不得包含 `--fix`。CI 只运行只读命令,任何自动修改都由开发者或 Agent 在提交前显式执行。
## ESLint 演进:先恢复可执行门禁
ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业务代码修复。
第一阶段完成以下事项:
1. 使用 ESLint 10 flat config,移除旧 `.eslintrc`
2. 不恢复已删除的 `@antfu/eslint-config-vue`
3. 显式组合 JavaScript、TypeScript、Vue 和经过审计的 SonarJS 规则。
4. 使用 ESLint 原生 AST 限制维护仍成立的模块边界,并删除只约束 import 拼写或已无现实对象的历史 regex 规则。
5. 只检查 JavaScript、TypeScript、Vue 与相关测试文件;Markdown、JSON、YAML、TOML、构建产物和生成文件不在首阶段扩展范围内。
6. 默认 lint 只读,并提供单独的 `lint:fix`
7. 对确有价值但存在存量的问题使用明确基线或分阶段启用,不通过全仓自动修复制造大面积混合变更。
8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:coverage` CI 自动执行。
`eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline;修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`
迁移完成的最低验收为:
```sh
yarn --frozen-lockfile
yarn lint
yarn typecheck
yarn test:coverage
yarn build
```
若新配置导致大量与业务目标无关的格式、排序或额外文件类型问题,应停止扩大规则范围,先调整配置职责;不得以一次性自动修复数百个文件作为通过迁移的手段。
## ESLint CI:先观察,再强制
ESLint 本地检查稳定后,Pull Request workflow 增加全仓 `yarn lint`
1. 初始阶段作为普通 check 运行,不立即配置 required check。
2. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
3. 连续多个 PR 稳定通过后,再由维护者决定是否设为 required。
ESLint CI 必须检查全仓受管源码,而不是只检查 PR 变更文件。已有问题通过规则选择或受控基线管理,保证新问题不能借由“只检查改动行”绕过项目约束。
## Prettier 演进:改到即格式化
Prettier 采用与后端 `unittest → pytest` 相同的渐进原则:不为转换而转换,修改到存量文件时再纳入统一格式。
- **新文件**:必须通过当前 Prettier 配置。
- **修改文件**:Agent 或开发者在提交前对本次触及的受支持文件运行 Prettier。
- **未修改存量文件**:暂时保持原样,不建立一次性全仓格式化任务。
- **大型或高冲突文件**:若格式化产生大量机械差异,格式调整与业务修改拆分为独立 commit;必要时暂缓该文件并记录原因。
- **生成文件**:按生成器事实源管理,不直接用 Prettier 修补生成结果;需要统一格式时修改生成器或生成配置。
Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,并作为变更范围门禁运行。检查脚本应:
1. 从 PR base 与 head 计算文件集合。
2. 排除已删除文件、构建产物、覆盖率报告和生成文件。
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`
4. 文件集合为空时正常通过。
该门禁不要求未修改的存量文件通过 Prettier,但所有进入 PR diff 的受支持文件都必须通过。Prettier 是文件级格式化工具,因此修改存量文件时检查的是整个文件,而不是仅检查变更行。Agent 或开发者应在提交前运行同一项目脚本完成格式化;CI 只验证结果,不自动提交修改。
禁止在普通业务 PR 中运行全仓 `yarn format`。触及文件格式化仍可能重排整个文件;差异过大时应拆分 commit,便于 reviewer 区分机械格式与业务逻辑。
具体交付拆分为:
1. Prettier 基础设施 PR:安装项目 CLI,定义配置、命令、变更文件选择脚本和回归测试,不修改 workflow。
2. 变更文件门禁 PR:复用已合并脚本接入 Pull Request workflow;验证 fork PR、文件重命名、删除文件、空文件集和特殊文件名后,将该稳定 check 配置为 required。
3. 全仓门禁 PR:仅在存量收敛条件满足后择机实施,不与变更文件门禁绑定。
## 全仓格式门禁的启用条件
不单独安排大爆炸式格式化阶段。存量通过日常“改到即格式化”逐步收敛,并定期执行全仓 `yarn format:check` 观察剩余范围。
变更文件 required check 不需要等待全仓收敛。只有同时满足以下条件,才把 Prettier 从变更文件 required check 切换为全仓 required check
- 全仓 `yarn format:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
- 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。
- `yarn lint``yarn typecheck``yarn test:coverage``yarn build` 在格式收敛后全部通过。
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
- workflow 已以非 required 状态稳定运行多个 PR。
最终 Pull Request 门禁目标为:
```sh
yarn --frozen-lockfile
yarn format:check
yarn lint
yarn typecheck
yarn test:coverage
yarn build
```
PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代可重复执行的项目命令。
## Agent 与开发者提交规范
无论改动由 Agent 还是人工完成,提交前遵循同一顺序:
1. 只对本次新增或修改的文件执行 Prettier。
2. 运行全仓只读 lint。
3. 运行 typecheck。
4. 按影响面运行聚焦测试;提交 PR 前运行覆盖率门禁。
5. 涉及构建配置、依赖、Vue SFC 或模块联邦时运行生产 build。
编辑器设置不得成为通过验证的前提。文档、Agent 指令和 PR 说明应引用 `yarn` 命令,而不是“在 VS Code 保存一次”或依赖某个 IDE 扩展完成修复。
## 风险与回滚
| 风险 | 控制方式 | 回滚边界 |
| --- | --- | --- |
| ESLint 自动修复改变语义 | 默认 lint 只读;基础设施迁移不批量 fix | 独立回退规则或依赖提交 |
| 格式化制造大面积冲突 | 改到即格式化;大型文件拆分机械 commit | 回退单文件格式 commit |
| 新规则让所有 PR 变红 | 先测量、再启用;使用受控基线 | 暂停单条规则,不回退整套工具链 |
| 检查范围扩展到生成物或文档 | 首阶段限制文件类型并显式 ignore | 收窄 flat config 文件匹配 |
| Node 最低版本阻断安装 | 最低保持 20.19,Node 24 作为推荐和主 CI | 保持兼容 job,版本提升独立处理 |
| 全仓 required check 配置过早 | 先强制变更文件,持续观察全仓剩余范围 | 保留变更文件门禁,暂缓全仓切换 |
| ESLint 与 Prettier 循环改写 | ESLint 关闭格式规则,Prettier 单独负责格式 | 关闭冲突规则并增加回归样例 |
依赖迁移、规则迁移、存量代码修复、格式化和 GitHub required checks 应保持可独立评审、验证和回退,禁止合并成一个难以定位风险的大型变更。
+4 -3
View File
@@ -67,7 +67,7 @@ tests/
## 新增测试 ## 新增测试
1. 在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts` 1. 业务测试在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`;工具链配置契约测试放在 `tests/config/`
2. 纯函数、store 和无渲染模块直接使用 Vitest;Vue 组件使用标准渲染入口。 2. 纯函数、store 和无渲染模块直接使用 Vitest;Vue 组件使用标准渲染入口。
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。 3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。 4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
@@ -76,7 +76,7 @@ tests/
## 配置边界 ## 配置边界
Vitest 收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。 Vitest 收集 `src/**/__tests__/**/*.spec.ts``tests/config/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。配置契约测试随全量测试执行,但不加入业务源码覆盖率统计范围。
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/` 当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/`
@@ -86,9 +86,10 @@ Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue
yarn test # watch 模式 yarn test # watch 模式
yarn test:run # 单次运行 yarn test:run # 单次运行
yarn test:coverage # 单次运行并检查覆盖率 yarn test:coverage # 单次运行并检查覆盖率
yarn test:lint-config # 聚焦执行 ESLint 配置契约测试
yarn typecheck yarn typecheck
yarn lint yarn lint
yarn build yarn build
``` ```
Pull Request 测试工作流使用 Node 24 LTS 和 frozen lockfile,依次执行类型检查和覆盖率门禁。现有 lint 基线问题按仓库当前维护约定单独处理,新增测试代码不得引入新的 lint 错误 Pull Request 测试工作流使用 Node 24 LTS 和 frozen lockfile,依次执行类型检查和覆盖率门禁。ESLint、Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)渐进接入,新增测试代码不得引入新的 lint 或格式问题
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
import js from '@eslint/js'
import sonarjs from 'eslint-plugin-sonarjs'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
const javascriptFiles = [
'**/*.{js,mjs,cjs,jsx}',
]
const typescriptFiles = [
'**/*.{ts,tsx}',
'**/*.vue',
]
const managedFiles = [
...javascriptFiles,
...typescriptFiles,
]
const browserFiles = [
'src/**/*.{js,jsx,ts,tsx,vue}',
'examples/**/src/**/*.{js,jsx,ts,tsx,vue}',
]
const nodeFiles = [
'**/*.config.{js,mjs,cjs,ts,mts,cts}',
'public/service.js',
'scripts/**/*.{js,mjs,cjs,ts,mts,cts}',
]
const restrictedCoreSourcePattern = '/(?:^|\\/)@core(?:\\/|$)/'
// @layouts 是 @core 的下游依赖;禁止反向引用,避免两层形成直接循环。
const layoutCoreImportRestrictions = [
'ImportDeclaration',
'ExportNamedDeclaration',
'ExportAllDeclaration',
'ImportExpression',
].map(nodeType => ({
selector: `${nodeType}[source.value=${restrictedCoreSourcePattern}]`,
message: "The '@layouts' module must not import from '@core'.",
}))
const nonLiteralLayoutDynamicImportRestriction = {
selector: "ImportExpression:not([source.type='Literal'])",
message: 'Dynamic imports in @layouts must use a literal path so module boundaries remain statically verifiable.',
}
const viteGlobCallSelectors = [
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.name=/^glob(?:Eager)?$/]",
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.value=/^glob(?:Eager)?$/]",
]
const layoutGlobRestrictions = viteGlobCallSelectors.map(callSelector => ({
selector: callSelector,
message: 'import.meta.glob is not allowed in @layouts because wildcard targets cannot be proven to stay within the module boundary.',
}))
const sonarRules = {
'sonarjs/array-callback-without-return': 'error',
'sonarjs/code-eval': 'error',
'sonarjs/empty-string-repetition': 'error',
'sonarjs/no-all-duplicated-branches': 'error',
'sonarjs/no-dead-store': 'error',
'sonarjs/no-duplicated-branches': 'error',
'sonarjs/no-element-overwrite': 'error',
'sonarjs/no-hardcoded-passwords': 'error',
'sonarjs/no-hardcoded-secrets': 'error',
'sonarjs/no-identical-conditions': 'error',
'sonarjs/no-identical-expressions': 'error',
'sonarjs/no-ignored-exceptions': 'error',
'sonarjs/no-unthrown-error': 'error',
'sonarjs/no-use-of-empty-return-value': 'error',
'sonarjs/reduce-initial-value': 'error',
'sonarjs/slow-regex': 'error',
'sonarjs/stateful-regex': 'error',
'sonarjs/super-linear-regex': 'error',
}
const typescriptConfigs = tseslint.configs.recommended.map(config => ({
...config,
files: typescriptFiles,
}))
const vueConfigs = pluginVue.configs['flat/essential'].map(config => ({
...config,
files: ['**/*.vue'],
}))
export default defineConfig([
globalIgnores([
'**/node_modules/**',
'**/dist/**',
'**/coverage/**',
'**/.worktrees/**',
'public/plugin_icon/**',
'src/@iconify/**',
'**/*.d.ts',
]),
{
...js.configs.recommended,
name: 'moviepilot/javascript',
files: managedFiles,
},
...typescriptConfigs,
...vueConfigs,
{
name: 'moviepilot/vue-contracts',
files: ['**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/valid-v-slot': ['error', {
allowModifiers: true,
}],
},
},
{
name: 'moviepilot/language-options',
files: managedFiles,
linterOptions: {
reportUnusedDisableDirectives: 'off',
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
parser: tseslint.parser,
},
},
},
{
name: 'moviepilot/browser-globals',
files: browserFiles,
languageOptions: {
globals: globals.browser,
},
},
{
name: 'moviepilot/node-globals',
files: nodeFiles,
languageOptions: {
globals: globals.node,
},
},
{
name: 'moviepilot/sonarjs',
files: managedFiles,
plugins: {
sonarjs,
},
rules: sonarRules,
},
{
name: 'moviepilot/sonarjs-locales',
files: ['src/locales/**/*.{js,jsx,ts,tsx}'],
rules: {
'sonarjs/no-hardcoded-passwords': 'off',
'sonarjs/no-hardcoded-secrets': 'off',
},
},
{
name: 'moviepilot/source-hygiene',
files: managedFiles,
rules: {
'no-debugger': 'error',
},
},
{
name: 'moviepilot/layout-boundary',
files: ['src/@layouts/**/*.{js,jsx,ts,tsx,vue}'],
rules: {
'no-restricted-syntax': [
'error',
...layoutCoreImportRestrictions,
...layoutGlobRestrictions,
nonLiteralLayoutDynamicImportRestriction,
],
},
},
])
+15 -12
View File
@@ -12,8 +12,11 @@
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:lint-config": "vitest run tests/config/eslint-config.spec.ts",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx", "lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
"lint:suppressions:prune": "eslint . --prune-suppressions",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js", "build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
"postinstall": "npm run build:icons", "postinstall": "npm run build:icons",
"pkg": "pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe" "pkg": "pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe"
@@ -82,6 +85,7 @@
"webfontloader": "^1.6.28" "webfontloader": "^1.6.28"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@iconify-json/line-md": "^1.2.13", "@iconify-json/line-md": "^1.2.13",
"@iconify-json/lucide": "^1.2.85", "@iconify-json/lucide": "^1.2.85",
"@iconify-json/material-symbols": "^1.2.51", "@iconify-json/material-symbols": "^1.2.51",
@@ -107,22 +111,16 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/three": "^0.185.1", "@types/three": "^0.185.1",
"@types/webfontloader": "^1.6.34", "@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^4.1.1", "@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/coverage-v8": "3.2.7", "@vitest/coverage-v8": "3.2.7",
"@vue/compiler-dom": "3.5.13", "@vue/compiler-dom": "3.5.13",
"@vue/test-utils": "2.4.11", "@vue/test-utils": "2.4.11",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"eslint": "^9.18.0", "eslint": "^10.7.0",
"eslint-import-resolver-typescript": "^3.5.1", "eslint-plugin-sonarjs": "^4.2.0",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-vue": "^10.9.2",
"eslint-plugin-promise": "^7.2.1", "globals": "^17.3.0",
"eslint-plugin-regex": "^1.10.0",
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-vue": "^9.12.0",
"jsdom": "26.1.0", "jsdom": "26.1.0",
"msw": "2.15.0", "msw": "2.15.0",
"postcss": "^8.5.1", "postcss": "^8.5.1",
@@ -134,6 +132,7 @@
"terser": "^5.36.0", "terser": "^5.36.0",
"type-fest": "^4.15.0", "type-fest": "^4.15.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"typescript-eslint": "^8.64.0",
"unplugin-auto-import": "^19.0.0", "unplugin-auto-import": "^19.0.0",
"unplugin-vue-components": "^28.0.0", "unplugin-vue-components": "^28.0.0",
"unplugin-vue-define-options": "^1.5.3", "unplugin-vue-define-options": "^1.5.3",
@@ -144,10 +143,14 @@
"vite-plugin-vue-layouts": "^0.11.0", "vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.4", "vite-plugin-vuetify": "2.0.4",
"vitest": "3.2.7", "vitest": "3.2.7",
"vue-eslint-parser": "^10.4.1",
"vue-shepherd": "^4.1.0", "vue-shepherd": "^4.1.0",
"vue-tsc": "^2.0.10", "vue-tsc": "^2.0.10",
"workbox-build": "^7.3.0", "workbox-build": "^7.3.0",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
}, },
"packageManager": "yarn@1.22.18" "packageManager": "yarn@1.22.18",
"engines": {
"node": ">=20.19"
}
} }
+197
View File
@@ -0,0 +1,197 @@
import { ESLint } from 'eslint'
import { expect, it } from 'vitest'
const eslint = new ESLint()
const restrictedSyntaxRule = 'no-restricted-syntax'
const unusedVariablesRule = '@typescript-eslint/no-unused-vars'
/** 使用真实 flat config 检查虚拟源码,避免项目边界和新增问题门禁在依赖升级时静默丢失。 */
async function lintRuleIds(code: string, filePath: string) {
const [result] = await eslint.lintText(code, { filePath })
return result.messages.map(message => message.ruleId)
}
const cases = [
{
name: '允许图片使用通用源码别名',
code: "import '@/assets/images/example.png'",
filePath: 'src/components/valid-image-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: false,
},
{
name: '允许动态样式使用通用源码别名',
code: "import('@/styles/example.scss')",
filePath: 'src/components/valid-dynamic-style-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: false,
},
{
name: '拒绝 layouts 反向依赖 core',
code: "import '@core/utils'",
filePath: 'src/@layouts/invalid-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '允许普通组件依赖 core',
code: "import '@core/utils'",
filePath: 'src/components/valid-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: false,
},
{
name: '拒绝 layouts 通过通用别名反向依赖 core',
code: "import '@/@core/utils'",
filePath: 'src/@layouts/invalid-generic-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 通过相对路径反向依赖 core',
code: "import '../../@core/utils'",
filePath: 'src/@layouts/components/invalid-relative-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 通过归一化相对路径反向依赖 core',
code: "import './../@core/utils'",
filePath: 'src/@layouts/invalid-normalized-relative-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 通过折返路径反向依赖 core',
code: "import '../../@layouts/../@core/utils'",
filePath: 'src/@layouts/components/invalid-detour-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 动态反向依赖 core',
code: "import('@/@core/utils')",
filePath: 'src/@layouts/invalid-dynamic-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 使用无法静态验证边界的动态路径',
code: "const file = 'utils'; import(`../@core/${file}.ts`)",
filePath: 'src/@layouts/invalid-template-dynamic-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '允许 layouts 使用边界内的字面量动态路径',
code: "import('../utils')",
filePath: 'src/@layouts/valid-literal-dynamic-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: false,
},
{
name: '拒绝 layouts 通过 Vite glob 反向加载 core',
code: "import.meta.glob('../@core/**/*.ts')",
filePath: 'src/@layouts/invalid-core-glob.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 通过 Vite glob 数组反向加载 core',
code: "import.meta.glob(['../utils/*.ts', '../../@core/**/*.ts'])",
filePath: 'src/@layouts/components/invalid-core-glob-array.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 通过通用别名 Vite glob 反向加载 core',
code: "import.meta.glob('@/@core/**/*.ts')",
filePath: 'src/@layouts/invalid-alias-core-glob.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 使用无法证明边界安全的 Vite glob',
code: "import.meta.glob('../utils/*.ts')",
filePath: 'src/@layouts/invalid-layout-glob.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 使用可能跨越到 core 的通配 glob',
code: "import.meta.glob('../*/**/*.ts')",
filePath: 'src/@layouts/invalid-cross-boundary-glob.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '允许普通源码使用 Vite glob',
code: "import.meta.glob('../views/**/*.vue')",
filePath: 'src/components/valid-vite-glob.ts',
ruleId: restrictedSyntaxRule,
shouldReport: false,
},
{
name: '拒绝 layouts 通过通用别名折返到 core',
code: "export * from '@/@layouts/../@core/utils'",
filePath: 'src/@layouts/invalid-alias-detour-core-export.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 baseline 之外的普通 lint 问题',
code: 'const newUnusedValue = 1',
filePath: 'src/components/new-lint-violation.ts',
ruleId: unusedVariablesRule,
shouldReport: true,
},
{
name: '拒绝 TypeScript 源码保留 debugger',
code: 'debugger',
filePath: 'src/components/invalid-debugger.ts',
ruleId: 'no-debugger',
shouldReport: true,
},
{
name: '拒绝浏览器 JavaScript 使用 Node 全局变量',
code: 'process.env.NODE_ENV',
filePath: 'src/components/invalid-node-global.js',
ruleId: 'no-undef',
shouldReport: true,
},
{
name: '允许构建配置使用 Node 全局变量',
code: 'process.env.NODE_ENV',
filePath: 'example.config.js',
ruleId: 'no-undef',
shouldReport: false,
},
{
name: '允许 Vuetify 点号 slot 名称',
code: '<template><VDataTable><template #item.name /></VDataTable></template>',
filePath: 'src/components/valid-vuetify-slot.vue',
ruleId: 'vue/valid-v-slot',
shouldReport: false,
},
{
name: '允许界面效果使用伪随机数',
code: 'export const randomOffset = Math.random()',
filePath: 'src/components/valid-ui-random.ts',
ruleId: 'sonarjs/pseudo-random',
shouldReport: false,
},
{
name: '拒绝动态执行代码',
code: 'export const runCode = (source: string) => new Function(source)()',
filePath: 'src/components/invalid-dynamic-code.ts',
ruleId: 'sonarjs/code-eval',
shouldReport: true,
},
]
it.each(cases)('$name', async ({ code, filePath, ruleId, shouldReport }) => {
const ruleIds = await lintRuleIds(code, filePath)
expect(ruleIds.includes(ruleId)).toBe(shouldReport)
})
+4 -1
View File
@@ -260,7 +260,10 @@ export default defineConfig(({ mode }) => ({
url: 'http://localhost/', url: 'http://localhost/',
}, },
}, },
include: ['src/**/__tests__/**/*.spec.ts'], include: [
'src/**/__tests__/**/*.spec.ts',
'tests/config/**/*.spec.ts',
],
restoreMocks: true, restoreMocks: true,
server: { server: {
deps: { deps: {
+298 -760
View File
File diff suppressed because it is too large Load Diff