ci: 落地 85% 行覆盖率硬门禁并校正 AGENTS.md

This commit is contained in:
roymondchen 2026-09-01 15:43:14 +08:00
parent 08b610a4f2
commit 392f9ae7f0
6 changed files with 292 additions and 14 deletions

View File

@ -1 +1 @@
npm run test
npm run coverage

View File

@ -7,7 +7,11 @@
TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组件编辑、配置面板、预览发布等能力。支持 Vue 和 React 双框架 Runtime采用 pnpm monorepo 管理多个核心包。开源项目,同时支持内部业务定制。
**技术栈:** Vue 3, Element Plus, TypeScript, Vite, vitest, VitePress
编辑器本体使用 Vue 3UI 通过 `@tmagic/design` + adapter 接入 Element Plus 或 TDesign Vue Next。
**技术栈:** Vue 3, TypeScript, Vite, rolldown, vitest, VitePress, Element Plus / TDesign Vue Next
**环境:** Node.js `^20.19.0 || >=22.12.0`pnpm `11.21.0`
**当前版本:** `1.8.0-beta.25`
**主仓库:** `https://git.woa.com/vft-magic/tmagic-editor.git`
**开源仓库:** `https://github.com/Tencent/tmagic-editor.git`
@ -15,10 +19,10 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
关键目录:
- `packages/` — 核心编辑器包
- `runtime/` — Vue/React Runtime 实现
- `vue-components/` — Vue 组件封装
- `react-components/` — React 组件封装
- `packages/` — 核心 npm 包(`editor``form``stage``core``cli``data-source``schema``form-schema``design``utils``dep``table``element-plus-adapter``tdesign-vue-next-adapter`
- `runtime/` — Vue/React Runtime,以及 `vue-runtime-help``react-runtime-help``tmagic-form`
- `vue-components/` — Vue 业务组件封装
- `react-components/` — React 业务组件封装
- `playground/` — 演示 playground
- `docs/` — VitePress 文档
- `scripts/` — 构建和发布脚本
@ -26,9 +30,12 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
## 开发约定
**分支策略:** dev=dev, test/prod=master
**提交规范:** commitlint + husky`type: 描述`
**测试覆盖率:** 新增或修改的代码必须补充单元测试,覆盖率不低于 85%
**分支策略(内部约定):** 日常开发走 `dev`test/prod 对应 `master`。开源文档站与 playground 从 `dev` 发布。
**提交规范:** commitlint`@commitlint/config-conventional`+ husky格式 `type(scope): subject`,详见 `CONTRIBUTING.md`
**测试:** 新增或修改的代码必须补充单元测试,覆盖率不低于 85%lines`pnpm coverage` 执行两层硬门禁:
1. 全仓 lines ≥ 85%vitest `coverage.thresholds`
2. 工作区相对 HEAD 的 `packages/*/src` 变更逐文件 lines ≥ 85%(不含 design / UI adapter未测新文件按 0% 计)
pre-commit 跑 lint-staged 与 `pnpm check:type`pre-push 跑 `pnpm coverage`
**禁止事项:**
@ -43,14 +50,12 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
pnpm pg:react # 启动 React playground
pnpm build # 完整构建DTS + 包)
pnpm test # 运行测试
pnpm coverage # 运行测试、生成覆盖率,并执行 85% 门禁
pnpm check:type # TypeScript 类型检查
pnpm lint-fix # ESLint 修复
pnpm docs:dev # 启动文档开发
pnpm release # 发版
## 当前状态
**当前里程碑:** {待人工填写}
## 深入阅读
| 文档 | 说明 |

View File

@ -25,7 +25,7 @@
"docs:build": "vitepress build docs",
"reinstall": "pnpm clean:all && pnpm bootstrap",
"test": "vitest run",
"coverage": "vitest run --coverage",
"coverage": "vitest run --coverage && node scripts/check-coverage.mjs",
"prepare": "husky",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
"release": "node scripts/release.mjs"

133
scripts/check-coverage.mjs Normal file
View File

@ -0,0 +1,133 @@
/**
* 覆盖率硬门禁新增/修改的 packages 源码 lines 覆盖率必须 85%
* 范围与 vitest coverage include/exclude 对齐不含 design / UI adapter
*
* node scripts/check-coverage.mjs [--root dir] [--summary file] [--files a,b]
*/
import { execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
export const COVERAGE_THRESHOLD = 85;
const GATED_SOURCE = new RegExp('^packages/(?!design/|element-plus-adapter/|tdesign-vue-next-adapter/)[^/]+/src/');
const SOURCE_EXT = new RegExp('\\.(vue|ts|tsx|js|jsx|mjs)$');
const defaultRun = (command, cwd) => {
try {
return execSync(command, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
} catch {
return '';
}
};
export const isGatedSourceFile = (relPath) => {
const normalized = relPath.replaceAll('\\', '/');
return GATED_SOURCE.test(normalized) && SOURCE_EXT.test(normalized) && !normalized.includes('/tests/');
};
export const lookupFileCoverage = (summary, absPath) => {
if (!summary || typeof summary !== 'object') return null;
if (summary[absPath]) return summary[absPath];
const normalized = absPath.replaceAll('\\', '/');
if (summary[normalized]) return summary[normalized];
const match = Object.keys(summary).find((key) => key.replaceAll('\\', '/') === normalized);
return match ? summary[match] : null;
};
export const getLinesPct = (stats) => {
const pct = stats && stats.lines ? stats.lines.pct : undefined;
if (typeof pct === 'number' && Number.isFinite(pct)) return pct;
return 0;
};
export const checkChangedFilesCoverage = ({ changedFiles, summary, root, threshold = COVERAGE_THRESHOLD }) => {
const failures = [];
for (const rel of changedFiles) {
const normalized = rel.replaceAll('\\', '/');
if (!isGatedSourceFile(normalized)) continue;
const abs = path.resolve(root, normalized);
const pct = getLinesPct(lookupFileCoverage(summary, abs));
if (pct < threshold) {
failures.push({ file: normalized, pct });
}
}
return failures;
};
export const collectGitChangedFiles = ({ run = defaultRun, cwd } = {}) => {
const files = new Set();
const linesOf = (command) =>
String(run(command, cwd) || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
// 只检查工作区相对 HEAD 的变更,避免把已经合入分支、尚未补测的历史文件一次性卡住。
for (const file of linesOf('git diff --name-only --diff-filter=ACMR HEAD')) files.add(file);
for (const file of linesOf('git diff --name-only --cached --diff-filter=ACMR')) files.add(file);
for (const file of linesOf('git ls-files --others --exclude-standard')) files.add(file);
return [...files];
};
export const formatCoverageFailures = (failures, threshold = COVERAGE_THRESHOLD) => {
const header = `Coverage gate failed (lines < ${threshold}%):`;
const body = failures.map((item) => ` ${item.file}: ${item.pct}%`).join('\n');
return body ? `${header}\n${body}` : header;
};
export const runCoverageGate = ({
root,
summaryPath = path.join(root, 'coverage', 'coverage-summary.json'),
changedFiles,
run,
log = console,
} = {}) => {
if (!existsSync(summaryPath)) {
log.error(`Missing ${path.relative(root, summaryPath)}. Run pnpm coverage first.`);
return 1;
}
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
const files = changedFiles ?? collectGitChangedFiles({ run, cwd: root });
const failures = checkChangedFilesCoverage({ changedFiles: files, summary, root });
if (failures.length > 0) {
log.error(formatCoverageFailures(failures));
return 1;
}
return 0;
};
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
const { values } = parseArgs({
options: {
root: { type: 'string' },
summary: { type: 'string' },
files: { type: 'string' },
},
});
const root = path.resolve(values.root || path.dirname(fileURLToPath(import.meta.url)), values.root ? '.' : '..');
const summaryPath = values.summary
? path.resolve(root, values.summary)
: path.join(root, 'coverage', 'coverage-summary.json');
const changedFiles = values.files
? values.files
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: undefined;
process.exit(runCoverageGate({ root, summaryPath, changedFiles }));
}

View File

@ -0,0 +1,126 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, test } from 'vitest';
const cli = fileURLToPath(new URL('./check-coverage.mjs', import.meta.url));
const spawnCli = (args: string[]) => spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8' });
describe('scripts/check-coverage.mjs', () => {
const tmpDirs: string[] = [];
afterEach(() => {
for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true });
tmpDirs.length = 0;
});
const writeSummary = (root: string, summary: Record<string, unknown>) => {
const dir = path.join(root, 'coverage');
fs.mkdirSync(dir, { recursive: true });
const summaryPath = path.join(dir, 'coverage-summary.json');
fs.writeFileSync(summaryPath, JSON.stringify(summary));
return summaryPath;
};
test('缺少 coverage-summary.json 时失败', () => {
const result = spawnCli(['--root', os.tmpdir(), '--summary', 'not-exist.json']);
expect(result.status).toBe(1);
expect(result.stderr).toContain('Missing');
});
test('非门禁文件即使没有覆盖率也通过', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
tmpDirs.push(root);
writeSummary(root, { total: { lines: { pct: 90 } } });
const result = spawnCli([
'--root',
root,
'--files',
'AGENTS.md,packages/design/src/Button.vue,playground/src/main.ts',
]);
expect(result.status).toBe(0);
expect(result.stderr).toBe('');
});
test('变更源码 lines < 85% 时失败', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
tmpDirs.push(root);
const abs = path.join(root, 'packages/editor/src/Editor.vue');
writeSummary(root, {
[abs]: { lines: { pct: 58.46 } },
});
const result = spawnCli(['--root', root, '--files', 'packages/editor/src/Editor.vue']);
expect(result.status).toBe(1);
expect(result.stderr).toContain('Coverage gate failed (lines < 85%):');
expect(result.stderr).toContain('packages/editor/src/Editor.vue: 58.46%');
});
test('变更源码达到 85% 时通过', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
tmpDirs.push(root);
const abs = path.join(root, 'packages/core/src/App.ts');
writeSummary(root, {
[abs]: { lines: { pct: 85 } },
});
const result = spawnCli(['--root', root, '--files', 'packages/core/src/App.ts']);
expect(result.status).toBe(0);
});
test('新文件不在 summary 中视为 0%', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
tmpDirs.push(root);
writeSummary(root, { total: { lines: { pct: 90 } } });
const result = spawnCli(['--root', root, '--files', 'packages/utils/src/brand-new.ts']);
expect(result.status).toBe(1);
expect(result.stderr).toContain('packages/utils/src/brand-new.ts: 0%');
});
test('未传 --files 时从 git 工作区收集变更', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
tmpDirs.push(root);
spawnSync('git', ['init'], { cwd: root, encoding: 'utf8' });
fs.mkdirSync(path.join(root, 'packages/core/src'), { recursive: true });
const rel = 'packages/core/src/App.ts';
fs.writeFileSync(path.join(root, rel), 'export {}\n');
writeSummary(root, {
[path.join(root, rel)]: { lines: { pct: 90 } },
});
const result = spawnCli(['--root', root]);
expect(result.status).toBe(0);
});
test('已提交文件不参与逐文件门禁', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
tmpDirs.push(root);
spawnSync('git', ['init'], { cwd: root, encoding: 'utf8' });
fs.mkdirSync(path.join(root, 'packages/editor/src'), { recursive: true });
const rel = 'packages/editor/src/Editor.vue';
fs.writeFileSync(path.join(root, rel), 'export {}\n');
spawnSync('git', ['add', rel], { cwd: root, encoding: 'utf8' });
spawnSync('git', ['-c', 'user.email=test@tmagic.local', '-c', 'user.name=test', 'commit', '-m', 'init'], {
cwd: root,
encoding: 'utf8',
});
writeSummary(root, {
[path.join(root, rel)]: { lines: { pct: 58.46 } },
});
const result = spawnCli(['--root', root]);
expect(result.status).toBe(0);
});
});

View File

@ -31,6 +31,10 @@ export default defineConfig({
'./packages/element-plus-adapter/**',
'./packages/tdesign-vue-next-adapter/**',
],
reporter: ['text-summary', 'json-summary', 'html'],
thresholds: {
lines: 85,
},
},
projects: [
{
@ -77,6 +81,16 @@ export default defineConfig({
sequence: { groupOrder: 2 },
},
},
{
test: {
name: 'scripts',
include: ['./scripts/**/*.spec.ts'],
environment: 'node',
pool: 'forks',
isolate: false,
sequence: { groupOrder: 3 },
},
},
],
},
});