build(ci): 建立单仓工具链与质量门禁

统一 pnpm 10 + Node 24.15.0 + Turborepo 单仓,落地 ESLint 9 flat、Prettier、commitlint、husky 与 Gitea CI,作为后续批次的一致门禁。
This commit is contained in:
Stev_Wang committed 2026-11-02 17:20:17 +08:00
1 parent 75cedbe97b
commit b3a2a843bb
21 files changed
+10622

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+116
View File
@@ -0,0 +1,116 @@
name: ci
on:
push:
branches: [main, 'feat/**']
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
name: 质量门禁
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: lingxi
POSTGRES_PASSWORD: lingxi_dev_password
POSTGRES_DB: lingxi_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U lingxi -d lingxi_test"
--health-interval 5s
--health-timeout 3s
--health-retries 10
redis:
image: redis:7.4-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
NODE_ENV: test
LOG_LEVEL: error
DATABASE_URL: postgresql://lingxi:lingxi_dev_password@127.0.0.1:5432/lingxi_test?schema=public
REDIS_URL: redis://127.0.0.1:6379
JWT_SECRET: ci-only-access-secret
JWT_REFRESH_SECRET: ci-only-refresh-secret
WEB_ORIGIN: http://127.0.0.1:4173
VITE_PUBLIC_API_BASE_URL: http://127.0.0.1:3000/api/v1
VITE_PUBLIC_DEMO_SESSION: 'true'
PLAYWRIGHT_CHANNEL: ''
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 安装 pnpm
uses: pnpm/action-setup@v4
with:
version: 10.34.9
- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: 24.15.0
cache: pnpm
- name: 安装依赖
run: pnpm install --frozen-lockfile
- name: 格式检查
run: pnpm format:check
- name: Lint
run: pnpm lint
- name: 类型检查
run: pnpm typecheck
- name: 单元测试与覆盖率
run: pnpm test
- name: 构建
run: pnpm build
- name: 数据库迁移
run: pnpm exec prisma migrate deploy
- name: 集成测试
run: pnpm test:int
- name: 导出 OpenAPI 快照并检查破坏性变更
run: |
pnpm openapi:export
pnpm openapi:diff
- name: 安装 Playwright 浏览器
run: pnpm exec playwright install --with-deps chromium
- name: 端到端冒烟
run: pnpm test:e2e
- name: 归档测试产物
if: always()
uses: actions/upload-artifact@v4
with:
name: test-artifacts
if-no-files-found: ignore
path: |
coverage
apps/*/coverage
packages/*/coverage
test-results
playwright-report
openapi/lingxi.openapi.json
+9
View File
@@ -62,7 +62,16 @@ temp/
~$* ~$*
*.~lock.* *.~lock.*
# ========== 测试产物 ==========
playwright-report/
test-results/
# ========== 项目自定义 ========== # ========== 项目自定义 ==========
# 参考项目:仅本地参照,不纳入版本控制、不推送到远程仓库 # 参考项目:仅本地参照,不纳入版本控制、不推送到远程仓库
参考项目/ 参考项目/
项目参考/ 项目参考/
# 沙箱环境下的本地 pnpm 10 启动器与设计探索产物
.tools/
design/
# Vite 缓存
.vite/
+1
View File
@@ -0,0 +1 @@
pnpm exec commitlint --edit "$1"
+1
View File
@@ -0,0 +1 @@
pnpm exec lint-staged
+1
View File
@@ -0,0 +1 @@
pnpm typecheck
+18
View File
@@ -0,0 +1,18 @@
const quote = (file) => JSON.stringify(file);
// 生成的声明文件(如 openapi.d.ts)被 ESLint 忽略,跳过 lint 只做格式化,
// 避免 lint-staged 因 "File ignored" 警告而阻断提交。
const lintScripts = (filenames) => {
const lintable = filenames.filter((file) => !file.endsWith('.d.ts'));
const commands = [];
if (lintable.length > 0) {
commands.push(`eslint --fix --max-warnings=0 ${lintable.map(quote).join(' ')}`);
}
commands.push(`prettier --write ${filenames.map(quote).join(' ')}`);
return commands;
};
export default {
'*.{ts,tsx,js,mjs,cjs}': lintScripts,
'*.{json,md,css,scss,yml,yaml}': ['prettier --write'],
};
+2
View File
@@ -0,0 +1,2 @@
engine-strict=true
auto-install-peers=true
+1
View File
@@ -0,0 +1 @@
24.15.0
+15
View File
@@ -0,0 +1,15 @@
node_modules/
dist/
coverage/
.turbo/
.tools/
design/
docs/
项目参考/
openapi/
prisma/migrations/
pnpm-lock.yaml
*.tsbuildinfo
# 项目总纲保持原排版,避免格式化产生无关 diff
AGENTS.md
+28
View File
@@ -0,0 +1,28 @@
/** @type {import('@commitlint/types').UserConfig} */
export default {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
[
'feat',
'fix',
'docs',
'style',
'refactor',
'perf',
'test',
'build',
'ci',
'chore',
'revert',
],
],
'subject-case': [0],
'subject-empty': [2, 'never'],
'header-max-length': [2, 'always', 72],
'subject-full-stop': [2, 'never', '。'],
'body-max-line-length': [0],
},
};
+226
View File
@@ -0,0 +1,226 @@
// 灵犀 ESLint 9 flat config
// 依据:docs/工程规范/03-格式化与Lint.md
// 分层:根规则(全仓库)→ apps/web(React)→ packages/ui(图标统一出口)→ scripts(允许 console)
import js from '@eslint/js';
import importX from 'eslint-plugin-import-x';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
import globals from 'globals';
import tseslint from 'typescript-eslint';
const UI_LIB_MESSAGE = 'UI 组件库只能用 @douyinfe/semi-ui-19(工程规范 03 §3.2);禁装其他组件库。';
const ICON_MESSAGE =
'图标只能从 @lingxi/ui/icons 按语义名导入;禁止直接引用图标库或内联 SVG(AGENTS §3.3)。';
const COLOR_MESSAGE =
'业务代码禁止颜色字面量;颜色只能定义在 packages/ui/src/theme/ 的 token 中(工程规范 03 §3.2)。';
export default tseslint.config(
{
ignores: [
'**/node_modules/**',
'**/dist/**',
'**/coverage/**',
'**/.turbo/**',
'.tools/**',
'design/**',
'docs/**',
'openapi/**',
'prisma/migrations/**',
'项目参考/**',
'playwright-report/**',
'test-results/**',
'**/*.d.ts',
],
},
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
name: 'lingxi/base',
languageOptions: {
globals: { ...globals.node, ...globals.browser, ...globals.es2022 },
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
'import-x': importX,
'simple-import-sort': simpleImportSort,
},
settings: {
'import-x/resolver': {
typescript: {
project: ['./tsconfig.json', './apps/*/tsconfig.json', './packages/*/tsconfig.json'],
},
},
},
linterOptions: { reportUnusedDisableDirectives: 'error' },
rules: {
// ---- TypeScript 硬约束(AGENTS §3.2)----
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' },
],
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/restrict-template-expressions': [
'error',
{ allowNumber: true, allowBoolean: true },
],
'@typescript-eslint/naming-convention': [
'error',
{ selector: 'typeLike', format: ['PascalCase'] },
{ selector: 'enumMember', format: ['UPPER_CASE'] },
],
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
// ---- 导入与依赖方向 ----
'import-x/no-cycle': ['error', { maxDepth: 8, ignoreExternal: false }],
'import-x/no-self-import': 'error',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
// ---- 通用 ----
'no-console': 'error',
eqeqeq: ['error', 'always'],
'no-var': 'error',
'prefer-const': 'error',
complexity: ['warn', { max: 15 }],
'max-lines-per-function': [
'warn',
{ max: 50, skipBlankLines: true, skipComments: true, IIFEs: true },
],
},
},
// 纯 JS 配置文件不做类型检查
{
name: 'lingxi/js-config',
files: ['**/*.{js,mjs,cjs}'],
extends: [tseslint.configs.disableTypeChecked],
rules: {
'no-console': 'off',
'import-x/no-cycle': 'off',
},
},
// 应用层:Web(React 19)
{
name: 'lingxi/apps-web',
files: ['apps/web/**/*.{ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
'jsx-a11y': jsxA11y,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'jsx-a11y/alt-text': 'error',
'jsx-a11y/no-autofocus': 'warn',
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{ group: ['@douyinfe/semi-ui', '@douyinfe/semi-ui/*'], message: UI_LIB_MESSAGE },
{
group: [
'@remixicon/react',
'react-icons',
'react-icons/*',
'lucide-react',
'lucide',
'semi-icons',
'@douyinfe/semi-icons',
'@heroicons/*',
'@ant-design/icons',
],
message: ICON_MESSAGE,
},
{
group: [
'antd',
'@arco-design/web-react',
'element-plus',
'naive-ui',
'@mui/*',
'@chakra-ui/*',
],
message: UI_LIB_MESSAGE,
},
],
},
],
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/^#(?:[0-9a-fA-F]{3,8})$/]',
message: COLOR_MESSAGE,
},
{
selector: 'Literal[value=/^rgba?\\(/]',
message: COLOR_MESSAGE,
},
{
selector: "JSXOpeningElement[name.name='svg']",
message: ICON_MESSAGE,
},
],
},
},
// packages/ui:图标统一出口(只有 icons.ts 可直接引用 Remix Icon)
{
name: 'lingxi/ui-icons',
files: ['packages/ui/src/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{ patterns: [{ group: ['@remixicon/react'], message: ICON_MESSAGE }] },
],
},
},
{
name: 'lingxi/ui-icons-exit',
files: ['packages/ui/src/icons.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': ['error', { patterns: [] }],
},
},
// Nest 模块装饰器类按框架约定是空类
{
name: 'lingxi/nest-modules',
files: ['apps/api/**/*.module.ts'],
rules: {
'@typescript-eslint/no-extraneous-class': 'off',
},
},
// 脚本层:允许 console(CLI 输出)
{
name: 'lingxi/scripts',
files: ['scripts/**/*.{ts,mjs,js}', '**/scripts/**/*.ts', 'prisma/seed.ts', 'e2e/**/*.ts'],
rules: {
'no-console': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
// 测试文件:允许更松的表达
{
name: 'lingxi/tests',
files: ['**/*.spec.ts', '**/*.spec.tsx', '**/*.int.spec.ts', 'e2e/**/*.ts'],
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'max-lines-per-function': 'off',
complexity: 'off',
},
},
);
+68
View File
@@ -0,0 +1,68 @@
{
"name": "lingxi",
"version": "0.1.0",
"private": true,
"description": "灵犀 · 信息流广告素材一站式生产平台",
"packageManager": "pnpm@10.34.9",
"engines": {
"node": "24.15.0",
"pnpm": ">=10.0.0 <11.0.0"
},
"scripts": {
"preinstall": "node scripts/only-allow-pnpm.mjs",
"prepare": "husky",
"dev": "turbo run dev --parallel",
"build": "turbo run build",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "turbo run typecheck && tsc -p tsconfig.json --noEmit",
"test": "turbo run test",
"test:int": "turbo run test:int",
"test:e2e": "turbo run build && playwright test",
"compose:up": "docker compose -f infra/docker-compose.dev.yaml up -d --wait",
"compose:down": "docker compose -f infra/docker-compose.dev.yaml down",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:reset": "prisma migrate reset --force",
"db:seed": "tsx prisma/seed.ts",
"openapi:export": "tsx --tsconfig apps/api/tsconfig.json apps/api/src/scripts/export-openapi.ts && openapi-typescript openapi/lingxi.openapi.json -o apps/web/src/api/generated/openapi.d.ts",
"openapi:diff": "node scripts/openapi-diff.mjs",
"postinstall": "prisma generate"
},
"devDependencies": {
"@commitlint/cli": "20.5.3",
"@commitlint/config-conventional": "20.5.3",
"@eslint/js": "9.39.5",
"@playwright/test": "1.59.1",
"@prisma/client": "6.19.3",
"@types/node": "24.19.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitest/coverage-v8": "3.2.7",
"dotenv": "18.0.9",
"eslint": "9.39.5",
"eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7",
"eslint-plugin-simple-import-sort": "14.0.0",
"globals": "17.14.0",
"husky": "9.1.7",
"lint-staged": "16.4.0",
"openapi-typescript": "7.13.0",
"prettier": "3.9.10",
"prisma": "6.19.3",
"tsup": "8.5.1",
"tsx": "4.23.15",
"turbo": "2.11.10",
"typescript": "5.9.3",
"typescript-eslint": "8.72.1",
"vitest": "3.2.7"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
+9932
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
packages:
- 'apps/*'
- 'packages/*'
onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- esbuild
- prisma
- sharp
- unrs-resolver
overrides:
fastify: 5.12.7
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('prettier').Config} */
export default {
printWidth: 100,
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: true,
trailingComma: 'all',
arrowParens: 'always',
endOfLine: 'lf',
};
+10
View File
@@ -0,0 +1,10 @@
const userAgent = process.env.npm_config_user_agent ?? '';
if (!userAgent.startsWith('pnpm/')) {
console.error('');
console.error('✗ 灵犀项目只允许使用 pnpm 10 安装依赖(见 AGENTS.md §3)。');
console.error(' 请执行:corepack enable && pnpm install');
console.error(' 或使用:npx pnpm@10.34.9 install');
console.error('');
process.exit(1);
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* OpenAPI 兼容性检查(A0 回退实现)。
*
* 优先使用 oasdiff 二进制;未安装时使用本脚本的结构化回退检查:
* - 删除 path / operation
* - 删除 components.schemas
* - 请求体必填字段新增、字段类型变更
*
* 基线来自 git HEAD 中的快照;首次提交(快照尚未入库)时跳过。
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const SNAPSHOT = resolve(process.cwd(), 'openapi/lingxi.openapi.json');
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
if (!existsSync(SNAPSHOT)) {
console.error(`未找到 OpenAPI 快照:${SNAPSHOT},请先执行 pnpm openapi:export`);
process.exit(1);
}
const current = JSON.parse(readFileSync(SNAPSHOT, 'utf8'));
let baseline = null;
try {
const raw = execFileSync('git', ['show', `HEAD:${'openapi/lingxi.openapi.json'}`], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
baseline = JSON.parse(raw);
} catch {
console.log('基线快照尚未入库,跳过 OpenAPI diff(首次提交)。');
process.exit(0);
}
const breaking = [];
for (const [path, baselinePath] of Object.entries(baseline.paths ?? {})) {
const currentPath = current.paths?.[path];
if (!currentPath) {
breaking.push(`删除路径:${path}`);
continue;
}
for (const method of HTTP_METHODS) {
if (baselinePath[method] !== undefined && currentPath[method] === undefined) {
breaking.push(`删除操作:${method.toUpperCase()} ${path}`);
}
}
}
for (const schemaName of Object.keys(baseline.components?.schemas ?? {})) {
if (current.components?.schemas?.[schemaName] === undefined) {
breaking.push(`删除 schema:${schemaName}`);
}
}
const baselineSchemas = baseline.components?.schemas ?? {};
const currentSchemas = current.components?.schemas ?? {};
for (const [name, baselineSchema] of Object.entries(baselineSchemas)) {
const currentSchema = currentSchemas[name];
if (currentSchema === undefined) {
continue;
}
const baselineRequired = new Set(baselineSchema.required ?? []);
for (const required of currentSchema.required ?? []) {
if (!baselineRequired.has(required)) {
breaking.push(`schema ${name} 新增必填字段:${required}`);
}
}
for (const [property, baselineProperty] of Object.entries(baselineSchema.properties ?? {})) {
const currentProperty = currentSchema.properties?.[property];
if (
currentProperty !== undefined &&
baselineProperty.type !== undefined &&
currentProperty.type !== undefined &&
baselineProperty.type !== currentProperty.type
) {
breaking.push(
`schema ${name}.${property} 类型变更:${baselineProperty.type} -> ${currentProperty.type}`,
);
}
}
}
if (breaking.length > 0) {
console.error('检测到破坏性 OpenAPI 变更:');
for (const item of breaking) {
console.error(` - ${item}`);
}
process.exit(1);
}
console.log('OpenAPI 无破坏性变更。');
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"jsx": "react-jsx",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"types": ["node"]
},
"include": ["playwright.config.ts", "scripts/**/*.ts", "e2e/**/*.ts", "prisma/seed.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "stream",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"typecheck": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"test:int": {
"dependsOn": ["^build"],
"cache": false
}
}
}