mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 16:56:40 +08:00
chore: add incremental Prettier tooling (#555)
This commit is contained in:
@@ -1,2 +1,8 @@
|
|||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# 自动生成文件的格式由各自生成器决定。
|
||||||
|
auto-imports.d.ts
|
||||||
|
components.d.ts
|
||||||
|
eslint-suppressions.json
|
||||||
|
/vite.config.*.timestamp-*.mjs
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"bracketSpacing": true,
|
"bracketSpacing": true,
|
||||||
"htmlWhitespaceSensitivity": "css",
|
"htmlWhitespaceSensitivity": "css",
|
||||||
"insertPragma": false,
|
"insertPragma": false,
|
||||||
"jsxBracketSameLine": false,
|
"bracketSameLine": false,
|
||||||
"jsxSingleQuote": true,
|
"jsxSingleQuote": true,
|
||||||
"printWidth": 120,
|
"printWidth": 120,
|
||||||
"proseWrap": "preserve",
|
"proseWrap": "preserve",
|
||||||
|
|||||||
+19
-8
@@ -5,7 +5,7 @@
|
|||||||
## 工具职责
|
## 工具职责
|
||||||
|
|
||||||
| 工具 | 职责 | 不负责 |
|
| 工具 | 职责 | 不负责 |
|
||||||
| --- | --- | --- |
|
| ---------------------- | ------------------------------------------------------------ | -------------------------------------- |
|
||||||
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
|
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
|
||||||
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
|
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
|
||||||
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
|
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
|
||||||
@@ -36,8 +36,9 @@ Prettier 独立运行,ESLint 中与格式重叠的规则保持关闭。任何
|
|||||||
yarn lint # 只读 ESLint 检查,不修改文件
|
yarn lint # 只读 ESLint 检查,不修改文件
|
||||||
yarn lint:fix # 显式执行 ESLint 安全修复
|
yarn lint:fix # 显式执行 ESLint 安全修复
|
||||||
yarn lint:suppressions:prune # 修复存量问题后裁剪过期 baseline
|
yarn lint:suppressions:prune # 修复存量问题后裁剪过期 baseline
|
||||||
yarn format # 使用 Prettier 格式化目标文件
|
yarn format # 格式化当前分支、暂存区、工作区和未跟踪文件中的受支持变更文件
|
||||||
yarn format:check # 检查目标文件格式,不修改文件
|
yarn format:check # 检查相同变更文件,不修改文件
|
||||||
|
yarn format:all:check # 只读测量全仓格式收敛情况
|
||||||
yarn typecheck
|
yarn typecheck
|
||||||
yarn test:run
|
yarn test:run
|
||||||
yarn test:coverage
|
yarn test:coverage
|
||||||
@@ -104,9 +105,17 @@ Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,
|
|||||||
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`。
|
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`。
|
||||||
4. 文件集合为空时正常通过。
|
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 只验证结果,不自动提交修改。
|
该门禁不要求未修改的存量文件通过 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:
|
变更文件 required check 不需要等待全仓收敛。只有同时满足以下条件,才把 Prettier 从变更文件 required check 切换为全仓 required check:
|
||||||
|
|
||||||
- 全仓 `yarn format:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
|
- 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
|
||||||
- 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。
|
- 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。
|
||||||
- `yarn lint`、`yarn typecheck`、`yarn test:coverage` 和 `yarn build` 在格式收敛后全部通过。
|
- `yarn lint`、`yarn typecheck`、`yarn test:coverage` 和 `yarn build` 在格式收敛后全部通过。
|
||||||
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
|
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
|
||||||
@@ -130,7 +141,7 @@ Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
yarn --frozen-lockfile
|
yarn --frozen-lockfile
|
||||||
yarn format:check
|
yarn format:all:check
|
||||||
yarn lint
|
yarn lint
|
||||||
yarn typecheck
|
yarn typecheck
|
||||||
yarn test:coverage
|
yarn test:coverage
|
||||||
@@ -154,7 +165,7 @@ PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代
|
|||||||
## 风险与回滚
|
## 风险与回滚
|
||||||
|
|
||||||
| 风险 | 控制方式 | 回滚边界 |
|
| 风险 | 控制方式 | 回滚边界 |
|
||||||
| --- | --- | --- |
|
| ---------------------------- | ------------------------------------------ | ------------------------------ |
|
||||||
| ESLint 自动修复改变语义 | 默认 lint 只读;基础设施迁移不批量 fix | 独立回退规则或依赖提交 |
|
| ESLint 自动修复改变语义 | 默认 lint 只读;基础设施迁移不批量 fix | 独立回退规则或依赖提交 |
|
||||||
| 格式化制造大面积冲突 | 改到即格式化;大型文件拆分机械 commit | 回退单文件格式 commit |
|
| 格式化制造大面积冲突 | 改到即格式化;大型文件拆分机械 commit | 回退单文件格式 commit |
|
||||||
| 新规则让所有 PR 变红 | 先测量、再启用;使用受控基线 | 暂停单条规则,不回退整套工具链 |
|
| 新规则让所有 PR 变红 | 先测量、再启用;使用受控基线 | 暂停单条规则,不回退整套工具链 |
|
||||||
|
|||||||
+12
-19
@@ -5,32 +5,20 @@ import globals from 'globals'
|
|||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
const javascriptFiles = [
|
const javascriptFiles = ['**/*.{js,mjs,cjs,jsx}']
|
||||||
'**/*.{js,mjs,cjs,jsx}',
|
|
||||||
]
|
|
||||||
|
|
||||||
const typescriptFiles = [
|
const typescriptFiles = ['**/*.{ts,tsx,mts,cts}', '**/*.vue']
|
||||||
'**/*.{ts,tsx,mts,cts}',
|
|
||||||
'**/*.vue',
|
|
||||||
]
|
|
||||||
|
|
||||||
const managedScriptExtensions = 'js,mjs,cjs,jsx,ts,tsx,mts,cts'
|
const managedScriptExtensions = 'js,mjs,cjs,jsx,ts,tsx,mts,cts'
|
||||||
|
|
||||||
const managedFiles = [
|
const managedFiles = [...javascriptFiles, ...typescriptFiles]
|
||||||
...javascriptFiles,
|
|
||||||
...typescriptFiles,
|
|
||||||
]
|
|
||||||
|
|
||||||
const browserFiles = [
|
const browserFiles = [
|
||||||
`src/**/*.{${managedScriptExtensions},vue}`,
|
`src/**/*.{${managedScriptExtensions},vue}`,
|
||||||
`examples/**/src/**/*.{${managedScriptExtensions},vue}`,
|
`examples/**/src/**/*.{${managedScriptExtensions},vue}`,
|
||||||
]
|
]
|
||||||
|
|
||||||
const nodeFiles = [
|
const nodeFiles = ['**/*.config.{js,mjs,cjs,ts,mts,cts}', 'public/service.js', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}']
|
||||||
'**/*.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 使用同一运行时边界。
|
// TypeScript 关闭核心 no-undef;显式禁止浏览器域中的 Node-only globals,保证 JS、TS 与 Vue 使用同一运行时边界。
|
||||||
const browserGlobalNames = new Set(Object.keys(globals.browser))
|
const browserGlobalNames = new Set(Object.keys(globals.browser))
|
||||||
@@ -67,7 +55,8 @@ const viteGlobCallSelectors = [
|
|||||||
|
|
||||||
const layoutGlobRestrictions = viteGlobCallSelectors.map(callSelector => ({
|
const layoutGlobRestrictions = viteGlobCallSelectors.map(callSelector => ({
|
||||||
selector: 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 = {
|
const sonarRules = {
|
||||||
@@ -105,6 +94,7 @@ export default defineConfig([
|
|||||||
globalIgnores([
|
globalIgnores([
|
||||||
'**/node_modules/**',
|
'**/node_modules/**',
|
||||||
'**/dist/**',
|
'**/dist/**',
|
||||||
|
'**/dev-dist/**',
|
||||||
'**/coverage/**',
|
'**/coverage/**',
|
||||||
'**/.worktrees/**',
|
'**/.worktrees/**',
|
||||||
'**/vite.config.*.timestamp-*.mjs',
|
'**/vite.config.*.timestamp-*.mjs',
|
||||||
@@ -124,9 +114,12 @@ export default defineConfig([
|
|||||||
files: ['**/*.vue'],
|
files: ['**/*.vue'],
|
||||||
rules: {
|
rules: {
|
||||||
'vue/multi-word-component-names': 'off',
|
'vue/multi-word-component-names': 'off',
|
||||||
'vue/valid-v-slot': ['error', {
|
'vue/valid-v-slot': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
allowModifiers: true,
|
allowModifiers: true,
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"lint": "eslint . --max-warnings=0",
|
"lint": "eslint . --max-warnings=0",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"lint:suppressions:prune": "eslint . --prune-suppressions",
|
"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",
|
"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"
|
||||||
@@ -125,6 +129,7 @@
|
|||||||
"msw": "2.15.0",
|
"msw": "2.15.0",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"postcss-html": "^1.5.0",
|
"postcss-html": "^1.5.0",
|
||||||
|
"prettier": "^3.9.5",
|
||||||
"stylelint": "^16.13.2",
|
"stylelint": "^16.13.2",
|
||||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||||
"stylelint-config-standard-scss": "^14.0.0",
|
"stylelint-config-standard-scss": "^14.0.0",
|
||||||
|
|||||||
@@ -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)
|
expect(ruleIds).toContain(restrictedGlobalsRule)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each(['example.config.mts', 'example.config.cts', 'scripts/example.mts', 'scripts/example.cts'])(
|
||||||
'example.config.mts',
|
'使用 TypeScript parser 解析 %s',
|
||||||
'example.config.cts',
|
async filePath => {
|
||||||
'scripts/example.mts',
|
|
||||||
'scripts/example.cts',
|
|
||||||
])('使用 TypeScript parser 解析 %s', async filePath => {
|
|
||||||
const ruleIds = await lintRuleIds('const value: string = "x"; console.log(value)', 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 () => {
|
it('忽略 Vite 生成的临时配置模块', async () => {
|
||||||
await expect(eslint.isPathIgnored('vite.config.ts.timestamp-1784340502076-0129cef6d3bbd8.mjs')).resolves.toBe(true)
|
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 () => {
|
it('不忽略普通 MJS 源码', async () => {
|
||||||
await expect(eslint.isPathIgnored('scripts/example.mjs')).resolves.toBe(false)
|
await expect(eslint.isPathIgnored('scripts/example.mjs')).resolves.toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
|
||||||
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
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:
|
pretty-bytes@^5.3.0:
|
||||||
version "5.6.0"
|
version "5.6.0"
|
||||||
resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz"
|
resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user