mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 16:13:28 +08:00
chore: add incremental Prettier tooling (#555)
This commit is contained in:
@@ -1,2 +1,8 @@
|
||||
dist
|
||||
node_modules
|
||||
node_modules
|
||||
|
||||
# 自动生成文件的格式由各自生成器决定。
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
eslint-suppressions.json
|
||||
/vite.config.*.timestamp-*.mjs
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"bracketSpacing": true,
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120,
|
||||
"proseWrap": "preserve",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
## 工具职责
|
||||
|
||||
| 工具 | 职责 | 不负责 |
|
||||
| --- | --- | --- |
|
||||
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
|
||||
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
|
||||
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
|
||||
| Vitest | 单元测试、组件测试和覆盖率门槛 | 生产构建与真实浏览器行为 |
|
||||
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
|
||||
| 工具 | 职责 | 不负责 |
|
||||
| ---------------------- | ------------------------------------------------------------ | -------------------------------------- |
|
||||
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
|
||||
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
|
||||
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
|
||||
| Vitest | 单元测试、组件测试和覆盖率门槛 | 生产构建与真实浏览器行为 |
|
||||
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
|
||||
|
||||
ESLint 配置由仓库显式维护,不继承 Antfu 等覆盖面较大的个人风格预设。JavaScript、TypeScript 与 Vue 使用各自面向正确性的 recommended/essential 基线;SonarJS 不整包展开 recommended,而只显式启用安全和正确性的高信号规则,避免插件升级隐式扩大检查范围。
|
||||
|
||||
@@ -36,8 +36,9 @@ Prettier 独立运行,ESLint 中与格式重叠的规则保持关闭。任何
|
||||
yarn lint # 只读 ESLint 检查,不修改文件
|
||||
yarn lint:fix # 显式执行 ESLint 安全修复
|
||||
yarn lint:suppressions:prune # 修复存量问题后裁剪过期 baseline
|
||||
yarn format # 使用 Prettier 格式化目标文件
|
||||
yarn format:check # 检查目标文件格式,不修改文件
|
||||
yarn format # 格式化当前分支、暂存区、工作区和未跟踪文件中的受支持变更文件
|
||||
yarn format:check # 检查相同变更文件,不修改文件
|
||||
yarn format:all:check # 只读测量全仓格式收敛情况
|
||||
yarn typecheck
|
||||
yarn test:run
|
||||
yarn test:coverage
|
||||
@@ -104,9 +105,17 @@ Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,
|
||||
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`。
|
||||
4. 文件集合为空时正常通过。
|
||||
|
||||
本地直接运行 `yarn format` 或 `yarn format:check` 时,脚本以可用的 `upstream/v2`、`origin/v2` 或本地 `v2` 为基线,并合并当前工作区与未跟踪文件。CI 使用显式引用保证输入可复现:
|
||||
|
||||
```sh
|
||||
yarn format:check --base <base-sha> --head <head-sha>
|
||||
```
|
||||
|
||||
文件名通过 Git 的 NUL 分隔输出和 Node 参数数组传递,不经过 shell 字符串拼接;重命名只检查新路径,删除文件、ignore 文件和 Prettier 无法推断 parser 的文件不进入格式检查。
|
||||
|
||||
该门禁不要求未修改的存量文件通过 Prettier,但所有进入 PR diff 的受支持文件都必须通过。Prettier 是文件级格式化工具,因此修改存量文件时检查的是整个文件,而不是仅检查变更行。Agent 或开发者应在提交前运行同一项目脚本完成格式化;CI 只验证结果,不自动提交修改。
|
||||
|
||||
禁止在普通业务 PR 中运行全仓 `yarn format`。触及文件格式化仍可能重排整个文件;差异过大时应拆分 commit,便于 reviewer 区分机械格式与业务逻辑。
|
||||
禁止在普通业务 PR 中直接运行 `prettier . --write` 或其他全仓格式写入命令。`yarn format` 只处理选择器返回的变更文件;单个存量文件被触及时仍可能整体重排,差异过大时应拆分 commit,便于 reviewer 区分机械格式与业务逻辑。
|
||||
|
||||
具体交付拆分为:
|
||||
|
||||
@@ -116,11 +125,13 @@ Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,
|
||||
|
||||
## 全仓格式门禁的启用条件
|
||||
|
||||
不单独安排大爆炸式格式化阶段。存量通过日常“改到即格式化”逐步收敛,并定期执行全仓 `yarn format:check` 观察剩余范围。
|
||||
不单独安排大爆炸式格式化阶段。存量通过日常“改到即格式化”逐步收敛,并定期执行全仓 `yarn format:all:check` 观察剩余范围。
|
||||
|
||||
Prettier 3.9.5 基础设施接入时,全仓只读检查在 `v2` 基线报告 203 个存量文件需要格式化。该数字是收敛起点而不是忽略白名单;变更文件仍必须完整通过检查,存量数量随日常修改逐步下降。
|
||||
|
||||
变更文件 required check 不需要等待全仓收敛。只有同时满足以下条件,才把 Prettier 从变更文件 required check 切换为全仓 required check:
|
||||
|
||||
- 全仓 `yarn format:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
|
||||
- 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
|
||||
- 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。
|
||||
- `yarn lint`、`yarn typecheck`、`yarn test:coverage` 和 `yarn build` 在格式收敛后全部通过。
|
||||
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
|
||||
@@ -130,7 +141,7 @@ Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,
|
||||
|
||||
```sh
|
||||
yarn --frozen-lockfile
|
||||
yarn format:check
|
||||
yarn format:all:check
|
||||
yarn lint
|
||||
yarn typecheck
|
||||
yarn test:coverage
|
||||
@@ -153,14 +164,14 @@ PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代
|
||||
|
||||
## 风险与回滚
|
||||
|
||||
| 风险 | 控制方式 | 回滚边界 |
|
||||
| --- | --- | --- |
|
||||
| ESLint 自动修复改变语义 | 默认 lint 只读;基础设施迁移不批量 fix | 独立回退规则或依赖提交 |
|
||||
| 格式化制造大面积冲突 | 改到即格式化;大型文件拆分机械 commit | 回退单文件格式 commit |
|
||||
| 新规则让所有 PR 变红 | 先测量、再启用;使用受控基线 | 暂停单条规则,不回退整套工具链 |
|
||||
| 检查范围扩展到生成物或文档 | 首阶段限制文件类型并显式 ignore | 收窄 flat config 文件匹配 |
|
||||
| Node 最低版本阻断安装 | 最低保持 20.19,Node 24 作为推荐和主 CI | 保持兼容 job,版本提升独立处理 |
|
||||
| 全仓 required check 配置过早 | 先强制变更文件,持续观察全仓剩余范围 | 保留变更文件门禁,暂缓全仓切换 |
|
||||
| ESLint 与 Prettier 循环改写 | ESLint 关闭格式规则,Prettier 单独负责格式 | 关闭冲突规则并增加回归样例 |
|
||||
| 风险 | 控制方式 | 回滚边界 |
|
||||
| ---------------------------- | ------------------------------------------ | ------------------------------ |
|
||||
| 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 应保持可独立评审、验证和回退,禁止合并成一个难以定位风险的大型变更。
|
||||
|
||||
@@ -5,32 +5,20 @@ import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
const javascriptFiles = [
|
||||
'**/*.{js,mjs,cjs,jsx}',
|
||||
]
|
||||
const javascriptFiles = ['**/*.{js,mjs,cjs,jsx}']
|
||||
|
||||
const typescriptFiles = [
|
||||
'**/*.{ts,tsx,mts,cts}',
|
||||
'**/*.vue',
|
||||
]
|
||||
const typescriptFiles = ['**/*.{ts,tsx,mts,cts}', '**/*.vue']
|
||||
|
||||
const managedScriptExtensions = 'js,mjs,cjs,jsx,ts,tsx,mts,cts'
|
||||
|
||||
const managedFiles = [
|
||||
...javascriptFiles,
|
||||
...typescriptFiles,
|
||||
]
|
||||
const managedFiles = [...javascriptFiles, ...typescriptFiles]
|
||||
|
||||
const browserFiles = [
|
||||
`src/**/*.{${managedScriptExtensions},vue}`,
|
||||
`examples/**/src/**/*.{${managedScriptExtensions},vue}`,
|
||||
]
|
||||
|
||||
const nodeFiles = [
|
||||
'**/*.config.{js,mjs,cjs,ts,mts,cts}',
|
||||
'public/service.js',
|
||||
'scripts/**/*.{js,mjs,cjs,ts,mts,cts}',
|
||||
]
|
||||
const nodeFiles = ['**/*.config.{js,mjs,cjs,ts,mts,cts}', 'public/service.js', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}']
|
||||
|
||||
// TypeScript 关闭核心 no-undef;显式禁止浏览器域中的 Node-only globals,保证 JS、TS 与 Vue 使用同一运行时边界。
|
||||
const browserGlobalNames = new Set(Object.keys(globals.browser))
|
||||
@@ -67,7 +55,8 @@ const viteGlobCallSelectors = [
|
||||
|
||||
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.',
|
||||
message:
|
||||
'import.meta.glob is not allowed in @layouts because wildcard targets cannot be proven to stay within the module boundary.',
|
||||
}))
|
||||
|
||||
const sonarRules = {
|
||||
@@ -105,6 +94,7 @@ export default defineConfig([
|
||||
globalIgnores([
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/dev-dist/**',
|
||||
'**/coverage/**',
|
||||
'**/.worktrees/**',
|
||||
'**/vite.config.*.timestamp-*.mjs',
|
||||
@@ -124,9 +114,12 @@ export default defineConfig([
|
||||
files: ['**/*.vue'],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/valid-v-slot': ['error', {
|
||||
allowModifiers: true,
|
||||
}],
|
||||
'vue/valid-v-slot': [
|
||||
'error',
|
||||
{
|
||||
allowModifiers: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:suppressions:prune": "eslint . --prune-suppressions",
|
||||
"format": "node scripts/format-changed.mjs --write",
|
||||
"format:check": "node scripts/format-changed.mjs --check",
|
||||
"format:all:check": "prettier . --check",
|
||||
"test:format-config": "vitest run tests/config/format-changed.spec.ts",
|
||||
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
|
||||
"postinstall": "npm run build:icons",
|
||||
"pkg": "pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe"
|
||||
@@ -125,6 +129,7 @@
|
||||
"msw": "2.15.0",
|
||||
"postcss": "^8.5.1",
|
||||
"postcss-html": "^1.5.0",
|
||||
"prettier": "^3.9.5",
|
||||
"stylelint": "^16.13.2",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
"stylelint-config-standard-scss": "^14.0.0",
|
||||
|
||||
201
scripts/format-changed.mjs
Normal file
201
scripts/format-changed.mjs
Normal file
@@ -0,0 +1,201 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, lstatSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import * as prettier from 'prettier'
|
||||
|
||||
const DEFAULT_BASE_REFS = ['upstream/v2', 'origin/v2', 'v2']
|
||||
const PRETTIER_ARGUMENT_BUDGET = 24_000
|
||||
const prettierCliPath = fileURLToPath(new URL('../node_modules/prettier/bin/prettier.cjs', import.meta.url))
|
||||
|
||||
/** 解析 Git 的 NUL 分隔输出,保留文件名中的空格、换行和通配符字符。 */
|
||||
function parseNullDelimited(output) {
|
||||
return output.toString('utf8').split('\0').filter(Boolean)
|
||||
}
|
||||
|
||||
/** 运行只返回仓库相对路径的 Git 查询。 */
|
||||
function gitPaths(cwd, args) {
|
||||
return parseNullDelimited(execFileSync('git', args, { cwd, encoding: 'buffer' }))
|
||||
}
|
||||
|
||||
/** 判断本地仓库是否存在可用于三点 diff 的提交引用。 */
|
||||
function hasCommitRef(cwd, ref) {
|
||||
return (
|
||||
spawnSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
|
||||
cwd,
|
||||
stdio: 'ignore',
|
||||
}).status === 0
|
||||
)
|
||||
}
|
||||
|
||||
/** 把外部引用解析为提交 SHA,后续 Git diff 不再解释用户提供的选项样式字符串。 */
|
||||
function resolveCommit(cwd, ref) {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
} catch {
|
||||
throw new Error(`无法解析 Git 提交引用:${ref}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 为本地命令选择现有 v2 基线;CI 应显式传入事件中的 base/head SHA。 */
|
||||
function resolveDefaultBase(cwd) {
|
||||
const base = DEFAULT_BASE_REFS.find(ref => hasCommitRef(cwd, ref))
|
||||
if (!base) {
|
||||
throw new Error('无法定位 v2 基线,请通过 --base <ref> 显式指定比较起点。')
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集当前分支相对基线的文件集合。
|
||||
* 显式 base/head 模式只读取提交区间,默认本地模式还会合并暂存区、工作区和未跟踪文件。
|
||||
*/
|
||||
function collectChangedPaths({ cwd, base, head }) {
|
||||
if (Boolean(base) !== Boolean(head)) {
|
||||
throw new Error('--base 与 --head 必须一起使用。')
|
||||
}
|
||||
|
||||
const explicitRange = Boolean(base)
|
||||
const resolvedBase = base ?? resolveDefaultBase(cwd)
|
||||
const resolvedHead = head ?? 'HEAD'
|
||||
const baseCommit = resolveCommit(cwd, resolvedBase)
|
||||
const headCommit = resolveCommit(cwd, resolvedHead)
|
||||
const paths = new Set(
|
||||
gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMR', '-z', `${baseCommit}...${headCommit}`]),
|
||||
)
|
||||
|
||||
if (!explicitRange) {
|
||||
for (const filePath of gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMR', '-z', 'HEAD'])) {
|
||||
paths.add(filePath)
|
||||
}
|
||||
for (const filePath of gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z'])) {
|
||||
paths.add(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
return [...paths]
|
||||
.filter(filePath => {
|
||||
const absolutePath = resolve(cwd, filePath)
|
||||
if (!existsSync(absolutePath)) return false
|
||||
try {
|
||||
return lstatSync(absolutePath).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.sort()
|
||||
}
|
||||
|
||||
/** 使用仓库 ignore 文件和 Prettier parser 推断筛出真正受格式化工具管理的文件。 */
|
||||
async function selectPrettierFiles(filePaths, cwd) {
|
||||
const ignorePaths = ['.gitignore', '.prettierignore'].map(filePath => resolve(cwd, filePath)).filter(existsSync)
|
||||
const selected = []
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const absolutePath = resolve(cwd, filePath)
|
||||
const fileInfo = await prettier.getFileInfo(absolutePath)
|
||||
if (!fileInfo.inferredParser) continue
|
||||
|
||||
let ignored = false
|
||||
for (const ignorePath of ignorePaths) {
|
||||
if ((await prettier.getFileInfo(absolutePath, { ignorePath })).ignored) {
|
||||
ignored = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!ignored) selected.push(filePath)
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
/** 按保守的参数长度预算拆批,避免大型 PR 超过操作系统命令行上限。 */
|
||||
function batchPaths(filePaths) {
|
||||
const batches = []
|
||||
let batch = []
|
||||
let length = 0
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
if (batch.length > 0 && length + filePath.length + 1 > PRETTIER_ARGUMENT_BUDGET) {
|
||||
batches.push(batch)
|
||||
batch = []
|
||||
length = 0
|
||||
}
|
||||
batch.push(filePath)
|
||||
length += filePath.length + 1
|
||||
}
|
||||
|
||||
if (batch.length > 0) batches.push(batch)
|
||||
return batches
|
||||
}
|
||||
|
||||
/** 通过参数数组调用项目内 Prettier,避免 shell 展开或拼接特殊文件名。 */
|
||||
function runPrettier(filePaths, mode, cwd) {
|
||||
if (filePaths.length === 0) {
|
||||
console.log('没有需要 Prettier 处理的变更文件。')
|
||||
return
|
||||
}
|
||||
|
||||
for (const batch of batchPaths(filePaths)) {
|
||||
const result = spawnSync(process.execPath, [prettierCliPath, mode, '--ignore-unknown', '--', ...batch], {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Prettier ${mode} 失败,退出码:${result.status ?? 'unknown'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析面向开发者和 CI 的稳定命令行契约。 */
|
||||
function parseArguments(args) {
|
||||
const options = { action: undefined, base: undefined, head: undefined }
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const argument = args[index]
|
||||
if (['--check', '--write', '--list'].includes(argument)) {
|
||||
if (options.action) throw new Error('只能指定 --check、--write 或 --list 中的一种操作。')
|
||||
options.action = argument
|
||||
continue
|
||||
}
|
||||
if (argument === '--base' || argument === '--head') {
|
||||
const value = args[index + 1]
|
||||
if (!value) throw new Error(`${argument} 缺少引用值。`)
|
||||
options[argument.slice(2)] = value
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
throw new Error(`未知参数:${argument}`)
|
||||
}
|
||||
|
||||
if (!options.action) throw new Error('必须指定 --check、--write 或 --list。')
|
||||
return options
|
||||
}
|
||||
|
||||
/** 执行变更文件选择与格式化;显式引用模式为后续 PR workflow 提供确定输入。 */
|
||||
async function main() {
|
||||
const cwd = process.cwd()
|
||||
const { action, base, head } = parseArguments(process.argv.slice(2))
|
||||
const changedPaths = collectChangedPaths({ cwd, base, head })
|
||||
const selectedPaths = await selectPrettierFiles(changedPaths, cwd)
|
||||
|
||||
if (action === '--list') {
|
||||
process.stdout.write(`${JSON.stringify(selectedPaths)}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
runPrettier(selectedPaths, action, cwd)
|
||||
}
|
||||
|
||||
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -243,21 +243,23 @@ it.each(nodeOnlyGlobalNames)('拒绝浏览器 TypeScript 使用 Node 全局变
|
||||
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)
|
||||
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)
|
||||
})
|
||||
expect(ruleIds).not.toContain(null)
|
||||
},
|
||||
)
|
||||
|
||||
it('忽略 Vite 生成的临时配置模块', async () => {
|
||||
await expect(eslint.isPathIgnored('vite.config.ts.timestamp-1784340502076-0129cef6d3bbd8.mjs')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('忽略 Vite PWA 开发构建产物', async () => {
|
||||
await expect(eslint.isPathIgnored('dev-dist/registerSW.js')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('不忽略普通 MJS 源码', async () => {
|
||||
await expect(eslint.isPathIgnored('scripts/example.mjs')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
127
tests/config/format-changed.spec.ts
Normal file
127
tests/config/format-changed.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const selectorScript = resolve(process.cwd(), 'scripts/format-changed.mjs')
|
||||
const temporaryRepositories: string[] = []
|
||||
|
||||
function git(repository: string, ...args: string[]) {
|
||||
return execFileSync('git', args, { cwd: repository, encoding: 'utf8' }).trim()
|
||||
}
|
||||
|
||||
function write(repository: string, filePath: string, content: string) {
|
||||
const absolutePath = resolve(repository, filePath)
|
||||
mkdirSync(dirname(absolutePath), { recursive: true })
|
||||
writeFileSync(absolutePath, content)
|
||||
}
|
||||
|
||||
function createRepository() {
|
||||
const repository = mkdtempSync(resolve(tmpdir(), 'moviepilot-format-changed-'))
|
||||
temporaryRepositories.push(repository)
|
||||
git(repository, 'init', '--initial-branch=v2')
|
||||
git(repository, 'config', 'user.name', 'Format Test')
|
||||
git(repository, 'config', 'user.email', 'format-test@example.invalid')
|
||||
write(repository, '.gitignore', 'coverage/\n')
|
||||
write(repository, '.prettierignore', 'generated/\n/vite.config.*.timestamp-*.mjs\n')
|
||||
write(repository, 'src/modified.ts', "export const value = 'before'\n")
|
||||
write(repository, 'src/rename old.ts', "export const renamed = 'before'\n")
|
||||
write(repository, 'src/deleted.ts', 'export const deleted = true\n')
|
||||
write(repository, 'coverage/tracked.ts', 'export const generated = true\n')
|
||||
git(repository, 'add', '.')
|
||||
git(repository, 'add', '--force', 'coverage/tracked.ts')
|
||||
git(repository, 'commit', '-m', 'base')
|
||||
|
||||
return repository
|
||||
}
|
||||
|
||||
function listSelected(repository: string, ...args: string[]) {
|
||||
const output = execFileSync(process.execPath, [selectorScript, '--list', ...args], {
|
||||
cwd: repository,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
return JSON.parse(output) as string[]
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const repository of temporaryRepositories.splice(0)) {
|
||||
rmSync(repository, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Prettier 变更文件选择器', () => {
|
||||
it('按 base 与 head 选择受支持文件并排除删除、忽略和符号链接', () => {
|
||||
const repository = createRepository()
|
||||
git(repository, 'switch', '-c', 'feature')
|
||||
write(repository, 'src/modified.ts', "export const value='after'\n")
|
||||
git(repository, 'mv', 'src/rename old.ts', 'src/rename [new].ts')
|
||||
git(repository, 'rm', 'src/deleted.ts')
|
||||
write(repository, 'src/new file.vue', '<template><div>new</div></template>\n')
|
||||
write(repository, 'src/line\nbreak.ts', 'export const lineBreak=true\n')
|
||||
symlinkSync('modified.ts', resolve(repository, 'src/linked.ts'))
|
||||
write(repository, 'generated/ignored.ts', 'export const ignored=true\n')
|
||||
write(repository, 'coverage/tracked.ts', 'export const generated=false\n')
|
||||
write(repository, 'notes.bin', 'not prettier source\n')
|
||||
git(repository, 'add', '-A')
|
||||
git(repository, 'commit', '-m', 'feature')
|
||||
|
||||
expect(listSelected(repository, '--base', 'v2', '--head', 'HEAD')).toEqual([
|
||||
'src/line\nbreak.ts',
|
||||
'src/modified.ts',
|
||||
'src/new file.vue',
|
||||
'src/rename [new].ts',
|
||||
])
|
||||
})
|
||||
|
||||
it('本地模式合并分支、工作区和未跟踪文件,并排除删除、忽略和符号链接', () => {
|
||||
const repository = createRepository()
|
||||
git(repository, 'switch', '-c', 'feature')
|
||||
write(repository, 'src/modified.ts', "export const value='working-tree'\n")
|
||||
write(repository, 'src/untracked file.ts', 'export const untracked=true\n')
|
||||
write(repository, 'scripts/feature.timestamp-parser.mjs', 'export const parser=true\n')
|
||||
write(repository, 'vite.config.ts.timestamp-123-generated.mjs', 'export const temporary=true\n')
|
||||
symlinkSync('modified.ts', resolve(repository, 'src/untracked-link.ts'))
|
||||
write(repository, 'generated/untracked.ts', 'export const ignored=true\n')
|
||||
git(repository, 'rm', 'src/deleted.ts')
|
||||
|
||||
expect(listSelected(repository)).toEqual([
|
||||
'scripts/feature.timestamp-parser.mjs',
|
||||
'src/modified.ts',
|
||||
'src/untracked file.ts',
|
||||
])
|
||||
})
|
||||
|
||||
it('没有受支持的变更文件时正常通过', () => {
|
||||
const repository = createRepository()
|
||||
|
||||
expect(listSelected(repository, '--base', 'HEAD', '--head', 'HEAD')).toEqual([])
|
||||
expect(
|
||||
spawnSync(process.execPath, [selectorScript, '--check', '--base', 'HEAD', '--head', 'HEAD'], {
|
||||
cwd: repository,
|
||||
}).status,
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('显式比较必须同时提供 base 与 head', () => {
|
||||
const repository = createRepository()
|
||||
const result = spawnSync(process.execPath, [selectorScript, '--check', '--base', 'HEAD'], {
|
||||
cwd: repository,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('--base 与 --head 必须一起使用。')
|
||||
})
|
||||
|
||||
it('通过参数数组安全格式化特殊文件名', () => {
|
||||
const repository = createRepository()
|
||||
git(repository, 'switch', '-c', 'feature')
|
||||
write(repository, '--special [file].ts', 'export const special=true\n')
|
||||
|
||||
execFileSync(process.execPath, [selectorScript, '--write'], { cwd: repository })
|
||||
|
||||
expect(readFileSync(resolve(repository, '--special [file].ts'), 'utf8')).toBe('export const special = true;\n')
|
||||
})
|
||||
})
|
||||
@@ -6827,6 +6827,11 @@ prelude-ls@^1.2.1:
|
||||
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
|
||||
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
||||
|
||||
prettier@^3.9.5:
|
||||
version "3.9.5"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.5.tgz#4fec97736e33b9d0b620b48914fe93b530e835ad"
|
||||
integrity sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==
|
||||
|
||||
pretty-bytes@^5.3.0:
|
||||
version "5.6.0"
|
||||
resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user