From 392f9ae7f0c0b88330d140bbf3310ace396f0f72 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Tue, 1 Sep 2026 15:43:14 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20=E8=90=BD=E5=9C=B0=2085%=20=E8=A1=8C?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=8E=87=E7=A1=AC=E9=97=A8=E7=A6=81=E5=B9=B6?= =?UTF-8?q?=E6=A0=A1=E6=AD=A3=20AGENTS.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .husky/pre-push | 2 +- AGENTS.md | 29 ++++--- package.json | 2 +- scripts/check-coverage.mjs | 133 +++++++++++++++++++++++++++++++++ scripts/check-coverage.spec.ts | 126 +++++++++++++++++++++++++++++++ vitest.config.ts | 14 ++++ 6 files changed, 292 insertions(+), 14 deletions(-) create mode 100644 scripts/check-coverage.mjs create mode 100644 scripts/check-coverage.spec.ts diff --git a/.husky/pre-push b/.husky/pre-push index e37998f9..cd150128 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -npm run test +npm run coverage diff --git a/AGENTS.md b/AGENTS.md index 35dc563c..4cb00c1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,11 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组件编辑、配置面板、预览发布等能力。支持 Vue 和 React 双框架 Runtime,采用 pnpm monorepo 管理多个核心包。开源项目,同时支持内部业务定制。 -**技术栈:** Vue 3, Element Plus, TypeScript, Vite, vitest, VitePress +编辑器本体使用 Vue 3;UI 通过 `@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 # 发版 -## 当前状态 - -**当前里程碑:** {待人工填写} - ## 深入阅读 | 文档 | 说明 | diff --git a/package.json b/package.json index 911e47ab..90c29652 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs new file mode 100644 index 00000000..62f10295 --- /dev/null +++ b/scripts/check-coverage.mjs @@ -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 })); +} diff --git a/scripts/check-coverage.spec.ts b/scripts/check-coverage.spec.ts new file mode 100644 index 00000000..2ba13fc9 --- /dev/null +++ b/scripts/check-coverage.spec.ts @@ -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) => { + 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); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 72b78815..0e46fb4a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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 }, + }, + }, ], }, });