mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
chore: migrate frontend linting to ESLint 10 (#549)
This commit is contained in:
@@ -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: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
24
|
||||||
@@ -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`。
|
||||||
|
|||||||
@@ -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.19;Node.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
@@ -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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user