refactor: 拆分ktworkflow模块
This commit is contained in:
parent
84309e9c9a
commit
13fc24063b
28
README.md
28
README.md
@ -8,11 +8,18 @@
|
||||
- 检查子项目环境:Git/SVN、包管理器、Node 版本、env 文件、Git 状态。
|
||||
- 生成防偏差工作包:把开工前检查、禁止项、风险扫描、验证计划汇总成一份可执行清单。
|
||||
- 生成验证建议:按后端、前端、样式、页面、部署、MCP 等变更类型给出轻量验证命令。
|
||||
- 固化改动后 review:验证计划和提交清单都会提醒执行 `kt_global_code_review` / `pnpm run global-review`。
|
||||
- 生成任务收尾包:把状态、验证计划、可选验证执行、全局 review 和历史清理收成一份结果。
|
||||
- 生成页面测试用例:内置“先写用例、可视化证据、事不过三”的测试闭环。
|
||||
- 生成接口测试计划:接口改动后输出真实调用命令和统一返回结构断言。
|
||||
- 生成业务链路测试计划:固化 Admin 登录、博客 CRUD、QQBot 扫码/自动回复、Web/Playground 回跳。
|
||||
- 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。
|
||||
- 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、备份和行数校验。
|
||||
- 生成专项组件工作流:KtTable、BlogArgon、AdminAuth、QQBot 的防踩坑清单和验证点。
|
||||
- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。
|
||||
- 清理历史产物:按目录最近修改时间只保留最近 3 轮测试/验证产物,模板目录永久保留。
|
||||
- 检查 env 策略和变更风险:提醒真实配置、锁文件、核心表格组件、部署链路等高风险改动。
|
||||
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量和当前变更风险;任何文件改动后都要跑一遍。
|
||||
- 生成或写入 `TASKS.md` 改动记录:默认 `dryRun=true`,确认后再落盘。
|
||||
- 生成提交前检查清单:校验 KT commit message 约定。
|
||||
|
||||
@ -67,6 +74,14 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
||||
| `kt_cleanup_process_plan` | 生成验证进程清理检查命令 |
|
||||
| `kt_env_policy` | 检查 env 文件现状和提交策略 |
|
||||
| `kt_risk_scan` | 扫描当前或传入变更文件的偏差风险 |
|
||||
| `kt_global_code_review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描 |
|
||||
| `kt_finish_task` | 生成任务收尾包,可选执行验证和历史清理 |
|
||||
| `kt_commit_plan` | 生成多仓库提交计划、建议 commit message 和检查项 |
|
||||
| `kt_push_plan` | 生成多仓库推送计划和远程异常提醒 |
|
||||
| `kt_business_test_plan` | 生成固化业务链路测试计划 |
|
||||
| `kt_remote_health_check` | 生成或执行远程只读健康检查命令 |
|
||||
| `kt_db_sync_plan` | 生成数据库同步安全向导 |
|
||||
| `kt_component_workflow` | 输出专项组件/链路防踩坑工作流 |
|
||||
| `kt_append_task_record` | 预览或写入 `TASKS.md` 改动记录 |
|
||||
| `kt_commit_checklist` | 生成提交前检查清单并校验 commit message |
|
||||
|
||||
@ -75,6 +90,7 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
||||
| 脚本 | 用途 |
|
||||
| --- | --- |
|
||||
| `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 |
|
||||
| `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,输出 JSON 复审报告。 |
|
||||
|
||||
## 项目别名
|
||||
|
||||
@ -90,7 +106,10 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
||||
|
||||
## 开发说明
|
||||
|
||||
- 主入口是 `src/server.ts`,工具入参类型集中在 `src/types.ts`。
|
||||
- 主入口是 `src/server.ts`,只负责 CLI 分支和 MCP Server 汇聚启动。
|
||||
- 工具入参类型集中在 `src/types.ts`,MCP 注册集中在 `src/registerTools.ts`。
|
||||
- `src/core/*` 放项目别名、工作区路径、命令执行、仓库/包管理器识别等基础能力。
|
||||
- `src/tools/*` 按功能拆分:检查、验证、测试、清理、风险、复审、任务记录和工作流计划。
|
||||
- MCP 客户端直接通过 `node --import tsx loader` 启动 TS 入口,不再保留旧的 `src/server.mjs`。
|
||||
- Node/npm/pnpm 版本异常时优先走 `nvm ls` + `nvm use`,不要用扫盘路径作为第一选择。
|
||||
|
||||
@ -98,6 +117,13 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
||||
|
||||
- 写代码前先调用 `kt_prepare_task`;只需要单项信息时再调用 `kt_read_context` 或 `kt_inspect_project`。
|
||||
- 多项目联动时先调用 `kt_inspect_all_projects`。
|
||||
- 要收尾时调用 `kt_finish_task`,默认只生成计划和 review;需要执行验证时显式传 `runValidation=true`。
|
||||
- 文件改动完成并验证后调用 `kt_global_code_review`,或运行 `pnpm run global-review`;它只读扫描,不删除文件、不提交代码。
|
||||
- 要提交或推送时先调用 `kt_commit_plan` / `kt_push_plan`,按仓库分组确认范围。
|
||||
- 要测真实业务链路时调用 `kt_business_test_plan`,选择 `admin-login`、`qqbot-auto-reply` 等 flow。
|
||||
- 远程服务排查先调用 `kt_remote_health_check`,默认只生成只读命令;需要执行时显式传 `execute=true`。
|
||||
- 数据库同步前调用 `kt_db_sync_plan`,先确认源库、目标库、备份库和校验点。
|
||||
- 改 KtTable、BlogArgon、AdminAuth、QQBot 前调用 `kt_component_workflow`,先看专项禁区。
|
||||
- 不确定任务边界时调用 `kt_guardrails`,先拿到“能做什么、不能做什么、怎么验证”。
|
||||
- 验证前调用 `kt_suggest_verification`,避免盲跑全量构建。
|
||||
- 页面测试前调用 `kt_create_page_test_case`,再执行 Playwright/浏览器测试。
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@kt/mcp-kt-workflow",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Reusable MCP workflow tools for the KT workspace.",
|
||||
"scripts": {
|
||||
"admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts",
|
||||
"cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history",
|
||||
"global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review",
|
||||
"self-test": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --self-test",
|
||||
"start": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
68
src/core/constants.ts
Normal file
68
src/core/constants.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import type { ProjectAlias, TaskType } from '../types.js';
|
||||
export const projectAliases = {
|
||||
admin: 'Vue/kt-template-admin',
|
||||
api: 'Node/kt-template-online-api',
|
||||
blog: 'Vue/kt-blog-web',
|
||||
mcp: 'mcp/ktWorkflow',
|
||||
playground: 'Vue/kt-template-online-playground',
|
||||
root: '.',
|
||||
web: 'Vue/kt-template-online-web',
|
||||
} satisfies Record<ProjectAlias, string>;
|
||||
|
||||
export const projectLabels = {
|
||||
admin: 'Vue Admin 后台',
|
||||
api: 'NestJS API 后端',
|
||||
blog: 'Blog Web 博客前台',
|
||||
mcp: 'KT Workflow MCP',
|
||||
playground: 'Playground 编辑器',
|
||||
root: 'KT 协作根目录',
|
||||
web: 'Web 前台展示',
|
||||
} satisfies Record<ProjectAlias, string>;
|
||||
|
||||
export const taskTypeValues = [
|
||||
'api',
|
||||
'backend',
|
||||
'commit',
|
||||
'database',
|
||||
'deploy',
|
||||
'docs',
|
||||
'frontend',
|
||||
'general',
|
||||
'mcp',
|
||||
'page',
|
||||
'style',
|
||||
] as const satisfies readonly TaskType[];
|
||||
|
||||
export const registeredToolNames = [
|
||||
'kt_read_context',
|
||||
'kt_inspect_project',
|
||||
'kt_inspect_all_projects',
|
||||
'kt_guardrails',
|
||||
'kt_prepare_task',
|
||||
'kt_suggest_verification',
|
||||
'kt_create_page_test_case',
|
||||
'kt_api_test_plan',
|
||||
'kt_cleanup_history',
|
||||
'kt_cleanup_process_plan',
|
||||
'kt_env_policy',
|
||||
'kt_risk_scan',
|
||||
'kt_global_code_review',
|
||||
'kt_finish_task',
|
||||
'kt_commit_plan',
|
||||
'kt_push_plan',
|
||||
'kt_business_test_plan',
|
||||
'kt_remote_health_check',
|
||||
'kt_db_sync_plan',
|
||||
'kt_component_workflow',
|
||||
'kt_append_task_record',
|
||||
'kt_commit_checklist',
|
||||
] as const;
|
||||
|
||||
export const defaultHistoryRoots = [
|
||||
'test-artifacts',
|
||||
'.codex-test-logs',
|
||||
'codex-test-logs',
|
||||
'.codex-verify',
|
||||
'.playwright-mcp',
|
||||
'.codex-db-sync',
|
||||
] as const;
|
||||
43
src/core/exec.ts
Normal file
43
src/core/exec.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { ExecResult } from '../types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
function getErrorText(error: unknown, key: 'message' | 'stderr' | 'stdout'): string {
|
||||
const value = (error as Partial<Record<typeof key, unknown>>)[key];
|
||||
if (typeof value === 'string') return value;
|
||||
if (Buffer.isBuffer(value)) return value.toString('utf8');
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function tryExecFile(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
timeoutMs = 20_000,
|
||||
): Promise<ExecResult> {
|
||||
try {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd,
|
||||
maxBuffer: 1024 * 1024 * 4,
|
||||
timeout: timeoutMs,
|
||||
windowsHide: true,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
stderr: result.stderr.trim(),
|
||||
stdout: result.stdout.trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
stderr: `${getErrorText(error, 'stderr') || getErrorText(error, 'message')}`.trim(),
|
||||
stdout: getErrorText(error, 'stdout').trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryPowerShell(command: string, cwd: string, timeoutMs = 120_000): Promise<ExecResult> {
|
||||
return tryExecFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', command], cwd, timeoutMs);
|
||||
}
|
||||
55
src/core/project.ts
Normal file
55
src/core/project.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { PackageInfo, PackageJsonLike, RepoType, ResolvedProject } from '../types.js';
|
||||
import { readJson } from './workspace.js';
|
||||
export function detectRepoType(projectPath: string): RepoType {
|
||||
if (existsSync(path.join(projectPath, '.git'))) return 'git';
|
||||
if (existsSync(path.join(projectPath, '.svn'))) return 'svn';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function detectPackageManager(projectPath: string): PackageInfo {
|
||||
const packageJsonPath = path.join(projectPath, 'package.json');
|
||||
const packageJson = readJson<PackageJsonLike>(packageJsonPath);
|
||||
const lockfiles = {
|
||||
npm: existsSync(path.join(projectPath, 'package-lock.json')),
|
||||
pnpm: existsSync(path.join(projectPath, 'pnpm-lock.yaml')),
|
||||
yarn: existsSync(path.join(projectPath, 'yarn.lock')),
|
||||
};
|
||||
const packageManagerSpec = packageJson?.packageManager || null;
|
||||
const packageManager =
|
||||
packageManagerSpec?.split('@')[0] ||
|
||||
(lockfiles.pnpm && 'pnpm') ||
|
||||
(lockfiles.yarn && 'yarn') ||
|
||||
(lockfiles.npm && 'npm') ||
|
||||
null;
|
||||
|
||||
return {
|
||||
engines: packageJson?.engines || {},
|
||||
lockfiles,
|
||||
packageManager,
|
||||
packageManagerSpec,
|
||||
scripts: packageJson?.scripts || {},
|
||||
};
|
||||
}
|
||||
|
||||
export function getTypecheckCommand(
|
||||
project: ResolvedProject,
|
||||
packageManager: string,
|
||||
scripts: Record<string, string>,
|
||||
): string | null {
|
||||
if (project.alias === 'admin') {
|
||||
return 'pnpm -F @vben/web-antdv-next run typecheck';
|
||||
}
|
||||
if (project.alias === 'blog') {
|
||||
return 'pnpm run type-check';
|
||||
}
|
||||
if (project.alias === 'web') {
|
||||
return 'pnpm exec vue-tsc --noEmit';
|
||||
}
|
||||
if (scripts.typecheck) return `${packageManager} run typecheck`;
|
||||
if (scripts['check:type']) return `${packageManager} run check:type`;
|
||||
if (scripts.check) return `${packageManager} run check`;
|
||||
return null;
|
||||
}
|
||||
97
src/core/workspace.ts
Normal file
97
src/core/workspace.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import type { ProjectAlias, ResolvedProject } from '../types.js';
|
||||
import { projectAliases, projectLabels } from './constants.js';
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const workspaceRoot = path.resolve(
|
||||
process.env.KT_WORKSPACE_ROOT || path.join(dirname, '../../../..'),
|
||||
);
|
||||
export function toPosix(value: string): string {
|
||||
return value.replaceAll(path.sep, '/');
|
||||
}
|
||||
|
||||
export function resolveInsideRoot(target = '.'): string {
|
||||
const absoluteTarget = path.isAbsolute(target)
|
||||
? path.resolve(target)
|
||||
: path.resolve(workspaceRoot, target);
|
||||
const relative = path.relative(workspaceRoot, absoluteTarget);
|
||||
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`Path is outside KT workspace: ${target}`);
|
||||
}
|
||||
|
||||
return absoluteTarget;
|
||||
}
|
||||
|
||||
export function getGlobalReviewCommand(): string {
|
||||
return `pnpm --dir ${toPosix(resolveInsideRoot(projectAliases.mcp))} run global-review`;
|
||||
}
|
||||
|
||||
export function resolveProject(project = 'root'): ResolvedProject {
|
||||
const normalized = (projectAliases as Record<string, string>)[project] || project;
|
||||
const absolutePath = resolveInsideRoot(normalized);
|
||||
const alias = (Object.entries(projectAliases) as Array<[ProjectAlias, string]>).find(
|
||||
([, relativePath]) => resolveInsideRoot(relativePath) === absolutePath,
|
||||
)?.[0];
|
||||
|
||||
return {
|
||||
alias: alias || null,
|
||||
label: alias ? projectLabels[alias] : '自定义路径',
|
||||
path: absolutePath,
|
||||
relativePath: toPosix(path.relative(workspaceRoot, absolutePath)) || '.',
|
||||
};
|
||||
}
|
||||
|
||||
export function readText(relativePath: string, fallback = ''): string {
|
||||
const filePath = resolveInsideRoot(relativePath);
|
||||
if (!existsSync(filePath)) return fallback;
|
||||
return readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
export function readTextIfExists(filePath: string, fallback = ''): string {
|
||||
if (!existsSync(filePath)) return fallback;
|
||||
return readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
export function readJson<T = Record<string, unknown>>(filePath: string): T | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function listImmediateChildren(targetPath: string): Array<{ name: string; type: 'directory' | 'file' }> {
|
||||
if (!existsSync(targetPath)) return [];
|
||||
|
||||
return readdirSync(targetPath, { withFileTypes: true }).map((item) => ({
|
||||
name: item.name,
|
||||
type: item.isDirectory() ? 'directory' : 'file',
|
||||
}));
|
||||
}
|
||||
|
||||
export function getRecentTaskRecords(count = 5): string {
|
||||
const tasks = readText('TASKS.md');
|
||||
const records = tasks
|
||||
.split(/\n(?=### \d{4}-\d{2}-\d{2}:)/)
|
||||
.filter((section) => section.startsWith('### '))
|
||||
.slice(0, count);
|
||||
|
||||
return records.join('\n\n').trim();
|
||||
}
|
||||
|
||||
export function formatDateInShanghai(date = new Date()): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
}).formatToParts(date);
|
||||
const map = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${map.year}-${map.month}-${map.day}`;
|
||||
}
|
||||
360
src/registerTools.ts
Normal file
360
src/registerTools.ts
Normal file
@ -0,0 +1,360 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { McpTextResponse } from './types.js';
|
||||
import { defaultHistoryRoots, taskTypeValues } from './core/constants.js';
|
||||
import { createCleanupProcessPlan, cleanupHistoryArtifacts } from './tools/cleanup.js';
|
||||
import { buildEnvPolicy, scanTaskRisk } from './tools/envRisk.js';
|
||||
import { buildGuardrails } from './tools/guardrails.js';
|
||||
import { inspectAllProjects, inspectProject } from './tools/inspect.js';
|
||||
import { buildGlobalCodeReview, defaultReviewProjects } from './tools/review.js';
|
||||
import { appendTaskRecord, createCommitChecklist, prepareTask, readWorkflowContext } from './tools/task.js';
|
||||
import { buildBusinessTestPlan, createApiTestPlan, createPageTestCase } from './tools/testing.js';
|
||||
import { buildVerificationPlan } from './tools/verification.js';
|
||||
import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js';
|
||||
export function response(data: unknown): McpTextResponse {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
text: typeof data === 'string' ? data : JSON.stringify(data, null, 2),
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function registerTools(server: McpServer): void {
|
||||
server.registerTool(
|
||||
'kt_read_context',
|
||||
{
|
||||
description: '读取 KT 根目录 AGENTS/SKILLS/TASKS,并返回项目清单和最近任务记录。',
|
||||
inputSchema: {
|
||||
taskRecordCount: z.number().int().min(0).max(20).default(5),
|
||||
},
|
||||
title: 'Read KT Workflow Context',
|
||||
},
|
||||
async (input) => response(readWorkflowContext(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_inspect_project',
|
||||
{
|
||||
description: '检查 KT 子项目的仓库类型、包管理器、Node 版本、env 文件和 Git 状态。',
|
||||
inputSchema: {
|
||||
includeGit: z.boolean().default(true),
|
||||
project: z.string().default('root'),
|
||||
},
|
||||
title: 'Inspect KT Project',
|
||||
},
|
||||
async (input) => response(await inspectProject(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_inspect_all_projects',
|
||||
{
|
||||
description: '一次性检查 KT 工作区所有已知项目,适合多仓库联动任务开工前使用。',
|
||||
inputSchema: {
|
||||
includeGit: z.boolean().default(true),
|
||||
includeRoot: z.boolean().default(true),
|
||||
},
|
||||
title: 'Inspect All KT Projects',
|
||||
},
|
||||
async (input) => response(await inspectAllProjects(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_guardrails',
|
||||
{
|
||||
description: '按任务类型生成 KT 防偏差清单,包含开工、改动、禁止项和验证约束。',
|
||||
inputSchema: {
|
||||
changeType: z.enum(taskTypeValues).optional(),
|
||||
includePageTest: z.boolean().default(false),
|
||||
paths: z.array(z.string()).default([]),
|
||||
project: z.string().default('root'),
|
||||
taskType: z.enum(taskTypeValues).default('general'),
|
||||
userRequest: z.string().optional(),
|
||||
},
|
||||
title: 'KT Guardrails',
|
||||
},
|
||||
async (input) => response(buildGuardrails(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_prepare_task',
|
||||
{
|
||||
description: '生成一份 KT 任务 work packet:上下文、项目检查、防偏差清单、风险扫描和验证计划。',
|
||||
inputSchema: {
|
||||
changedFiles: z.array(z.string()).default([]),
|
||||
entryUrl: z.string().optional(),
|
||||
includeGit: z.boolean().default(true),
|
||||
includePageTest: z.boolean().default(false),
|
||||
paths: z.array(z.string()).default([]),
|
||||
project: z.string().default('root'),
|
||||
recordTitle: z.string().optional(),
|
||||
taskType: z.enum(taskTypeValues).default('general'),
|
||||
userRequest: z.string().optional(),
|
||||
},
|
||||
title: 'Prepare KT Task',
|
||||
},
|
||||
async (input) => response(await prepareTask(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_suggest_verification',
|
||||
{
|
||||
description: '按 KT 规则为指定项目和变更类型生成轻量验证命令与注意事项。',
|
||||
inputSchema: {
|
||||
changeType: z.enum(taskTypeValues).default('general'),
|
||||
includePageTest: z.boolean().default(false),
|
||||
project: z.string().default('root'),
|
||||
},
|
||||
title: 'Suggest KT Verification',
|
||||
},
|
||||
async (input) => response(buildVerificationPlan(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_create_page_test_case',
|
||||
{
|
||||
description: '生成 KT 页面级可视化测试用例骨架,包含事不过三闭环规则。',
|
||||
inputSchema: {
|
||||
account: z.string().optional(),
|
||||
assertions: z.array(z.string()).default([]),
|
||||
entryUrl: z.string().optional(),
|
||||
project: z.string().default('admin'),
|
||||
steps: z.array(z.string()).default([]),
|
||||
title: z.string().optional(),
|
||||
},
|
||||
title: 'Create KT Page Test Case',
|
||||
},
|
||||
async (input) => response(createPageTestCase(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_api_test_plan',
|
||||
{
|
||||
description: '为后端接口改动生成真实调用测试计划和响应结构断言。',
|
||||
inputSchema: {
|
||||
auth: z.enum(['none', 'bearer']).default('bearer'),
|
||||
baseUrl: z.string().default('http://localhost:3000'),
|
||||
body: z.record(z.any()).optional(),
|
||||
contentType: z.boolean().default(true),
|
||||
endpoint: z.string().default('/api/待填写'),
|
||||
expectedFields: z.array(z.string()).default([]),
|
||||
method: z.enum(['DELETE', 'GET', 'PATCH', 'POST', 'PUT']).default('GET'),
|
||||
project: z.string().default('api'),
|
||||
},
|
||||
title: 'KT API Test Plan',
|
||||
},
|
||||
async (input) => response(createApiTestPlan(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_cleanup_history',
|
||||
{
|
||||
description: '清理 KT 根目录历史产物。默认 dryRun=true,只预览;执行时按 LastWriteTime 每个历史目录仅保留最近 3 轮,test-artifacts/_templates 永久保留。',
|
||||
inputSchema: {
|
||||
dryRun: z.boolean().default(true),
|
||||
keep: z.number().int().min(0).max(50).default(3),
|
||||
roots: z.array(z.string()).default([...defaultHistoryRoots]),
|
||||
},
|
||||
title: 'KT Cleanup History Artifacts',
|
||||
},
|
||||
async (input) => response(cleanupHistoryArtifacts(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_cleanup_process_plan',
|
||||
{
|
||||
description: '生成按项目路径和端口清理验证进程的 PowerShell 检查命令,不直接杀进程。',
|
||||
inputSchema: {
|
||||
ports: z.array(z.number().int().min(1).max(65_535)).default([]),
|
||||
project: z.string().default('root'),
|
||||
},
|
||||
title: 'KT Cleanup Process Plan',
|
||||
},
|
||||
async (input) => response(createCleanupProcessPlan(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_env_policy',
|
||||
{
|
||||
description: '检查指定项目 env 文件现状,并输出 KT 环境配置提交策略。',
|
||||
inputSchema: {
|
||||
project: z.string().default('root'),
|
||||
},
|
||||
title: 'KT Env Policy',
|
||||
},
|
||||
async (input) => response(buildEnvPolicy(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_risk_scan',
|
||||
{
|
||||
description: '按当前变更文件或传入文件列表扫描常见偏差风险和验证提醒。',
|
||||
inputSchema: {
|
||||
changedFiles: z.array(z.string()).default([]),
|
||||
project: z.string().default('root'),
|
||||
},
|
||||
title: 'KT Risk Scan',
|
||||
},
|
||||
async (input) => response(await scanTaskRisk(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_global_code_review',
|
||||
{
|
||||
description: '对 KT 已知子仓库做只读全局 CodeReview 扫描:Git 状态、tracked env/deploy/secrets、冲突标记、调试输出、疑似凭据字面量和当前变更风险。',
|
||||
inputSchema: {
|
||||
includeContentScan: z.boolean().default(true),
|
||||
includeRootScan: z.boolean().default(true),
|
||||
maxFindingsPerProject: z.number().int().min(1).max(200).default(20),
|
||||
projects: z.array(z.string()).default([...defaultReviewProjects]),
|
||||
},
|
||||
title: 'KT Global Code Review',
|
||||
},
|
||||
async (input) => response(await buildGlobalCodeReview(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_finish_task',
|
||||
{
|
||||
description: '一键生成任务收尾包:项目状态、验证计划、可选验证执行、全局 review、历史产物清理预览/执行和停止条件。',
|
||||
inputSchema: {
|
||||
changeType: z.enum(taskTypeValues).default('general'),
|
||||
cleanupHistory: z.boolean().default(false),
|
||||
includeRootScan: z.boolean().default(true),
|
||||
keepHistory: z.number().int().min(0).max(50).default(3),
|
||||
maxFindingsPerProject: z.number().int().min(1).max(200).default(20),
|
||||
projects: z.array(z.string()).default([...defaultReviewProjects]),
|
||||
runValidation: z.boolean().default(false),
|
||||
},
|
||||
title: 'KT Finish Task',
|
||||
},
|
||||
async (input) => response(await buildFinishTask(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_commit_plan',
|
||||
{
|
||||
description: '按 KT 多仓库约定生成提交计划:按仓库分组、建议 commit message、提交命令和提交前检查。',
|
||||
inputSchema: {
|
||||
includePush: z.boolean().default(false),
|
||||
message: z.string().optional(),
|
||||
projects: z.array(z.string()).default([...defaultReviewProjects]),
|
||||
},
|
||||
title: 'KT Commit Plan',
|
||||
},
|
||||
async (input) => response(await buildCommitPlan(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_push_plan',
|
||||
{
|
||||
description: '按 KT 多仓库约定生成推送计划:检查本地状态、待推提交、push 命令和远程异常提醒。',
|
||||
inputSchema: {
|
||||
branch: z.string().optional(),
|
||||
projects: z.array(z.string()).default([...defaultReviewProjects]),
|
||||
remote: z.string().default('origin'),
|
||||
},
|
||||
title: 'KT Push Plan',
|
||||
},
|
||||
async (input) => response(await buildPushPlan(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_business_test_plan',
|
||||
{
|
||||
description: '生成 KT 固化业务链路测试计划:Admin 登录、博客 CRUD、QQBot 扫码/自动回复、Web/Playground 鉴权回跳。',
|
||||
inputSchema: {
|
||||
baseUrl: z.string().optional(),
|
||||
environment: z.enum(['local', 'remote']).default('local'),
|
||||
flow: z
|
||||
.enum([
|
||||
'admin-login',
|
||||
'blog-crud',
|
||||
'qqbot-account-scan',
|
||||
'qqbot-auto-reply',
|
||||
'web-playground-auth',
|
||||
'all',
|
||||
])
|
||||
.default('all'),
|
||||
},
|
||||
title: 'KT Business Test Plan',
|
||||
},
|
||||
async (input) => response(buildBusinessTestPlan(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_remote_health_check',
|
||||
{
|
||||
description: '生成或执行飞牛 NAS 远程只读健康检查:SSH、Docker、Gitea、Jenkins、MySQL、MinIO、Mosquitto、NapCat、k3d/K8s。',
|
||||
inputSchema: {
|
||||
execute: z.boolean().default(false),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).max(65_535).default(2202),
|
||||
services: z.array(z.string()).default([]),
|
||||
sshTarget: z.string().optional(),
|
||||
},
|
||||
title: 'KT Remote Health Check',
|
||||
},
|
||||
async (input) => response(await buildRemoteHealthCheck(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_db_sync_plan',
|
||||
{
|
||||
description: '生成 KT 数据库同步安全向导:源/目标确认、GTID 规避、目标备份、行数校验和通过后删除备份库。',
|
||||
inputSchema: {
|
||||
backupName: z.string().optional(),
|
||||
source: z.enum(['local', 'remote']).default('local'),
|
||||
target: z.enum(['local', 'remote']).default('remote'),
|
||||
},
|
||||
title: 'KT DB Sync Plan',
|
||||
},
|
||||
async (input) => response(buildDbSyncPlan(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_component_workflow',
|
||||
{
|
||||
description: '按 KT 专项组件/链路输出防踩坑工作流:KtTable、BlogArgon、AdminAuth、QQBot。',
|
||||
inputSchema: {
|
||||
target: z.enum(['AdminAuth', 'BlogArgon', 'KtTable', 'QQBot']).default('KtTable'),
|
||||
},
|
||||
title: 'KT Component Workflow',
|
||||
},
|
||||
async (input) => response(buildComponentWorkflow(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_append_task_record',
|
||||
{
|
||||
description: '生成或写入 TASKS.md 改动记录。默认 dryRun=true,只预览不落盘。',
|
||||
inputSchema: {
|
||||
content: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
dryRun: z.boolean().default(true),
|
||||
scope: z.array(z.string()).default([]),
|
||||
testCase: z.string().optional(),
|
||||
title: z.string(),
|
||||
verification: z.string().optional(),
|
||||
},
|
||||
title: 'Append KT Task Record',
|
||||
},
|
||||
async (input) => response(appendTaskRecord(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kt_commit_checklist',
|
||||
{
|
||||
description: '生成 KT 子仓库提交前检查清单,并校验 commit message 是否符合约定。',
|
||||
inputSchema: {
|
||||
message: z.string().optional(),
|
||||
project: z.string().default('root'),
|
||||
},
|
||||
title: 'KT Commit Checklist',
|
||||
},
|
||||
async (input) => response(await createCommitChecklist(input)),
|
||||
);
|
||||
}
|
||||
74
src/selfTest.ts
Normal file
74
src/selfTest.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { registeredToolNames } from './core/constants.js';
|
||||
import { resolveInsideRoot } from './core/workspace.js';
|
||||
import { buildGuardrails } from './tools/guardrails.js';
|
||||
import { cleanupHistoryArtifacts } from './tools/cleanup.js';
|
||||
import { inspectProject } from './tools/inspect.js';
|
||||
import { prepareTask, readWorkflowContext } from './tools/task.js';
|
||||
import { buildGlobalCodeReview } from './tools/review.js';
|
||||
import { buildBusinessTestPlan } from './tools/testing.js';
|
||||
import { buildVerificationPlan } from './tools/verification.js';
|
||||
import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js';
|
||||
export async function runSelfTest(): Promise<void> {
|
||||
const data = {
|
||||
context: readWorkflowContext({ taskRecordCount: 2 }),
|
||||
guardrails: buildGuardrails({
|
||||
project: 'mcp',
|
||||
taskType: 'mcp',
|
||||
userRequest: '扩展 KT 工作区 MCP 可复用能力',
|
||||
}),
|
||||
historyCleanup: cleanupHistoryArtifacts({
|
||||
dryRun: true,
|
||||
keep: 3,
|
||||
}),
|
||||
inspectAdmin: await inspectProject({ includeGit: false, project: 'admin' }),
|
||||
prepareTask: await prepareTask({
|
||||
includeGit: false,
|
||||
project: 'mcp',
|
||||
taskType: 'mcp',
|
||||
userRequest: '扩展 KT 工作区 MCP 可复用能力',
|
||||
}),
|
||||
review: await buildGlobalCodeReview({
|
||||
includeContentScan: true,
|
||||
includeRootScan: true,
|
||||
maxFindingsPerProject: 10,
|
||||
projects: ['mcp'],
|
||||
}),
|
||||
businessTestPlan: buildBusinessTestPlan({
|
||||
flow: 'qqbot-auto-reply',
|
||||
}),
|
||||
commitPlan: await buildCommitPlan({
|
||||
projects: ['mcp'],
|
||||
}),
|
||||
componentWorkflow: buildComponentWorkflow({
|
||||
target: 'KtTable',
|
||||
}),
|
||||
dbSyncPlan: buildDbSyncPlan({}),
|
||||
finishTask: await buildFinishTask({
|
||||
cleanupHistory: false,
|
||||
projects: ['mcp'],
|
||||
runValidation: false,
|
||||
}),
|
||||
pushPlan: await buildPushPlan({
|
||||
projects: ['mcp'],
|
||||
}),
|
||||
remoteHealthCheck: await buildRemoteHealthCheck({
|
||||
execute: false,
|
||||
services: ['ssh', 'docker'],
|
||||
}),
|
||||
verification: buildVerificationPlan({
|
||||
changeType: 'mcp',
|
||||
project: 'mcp',
|
||||
}),
|
||||
};
|
||||
const outputDir = resolveInsideRoot('.codex-verify/ktWorkflow');
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(outputDir, 'self-test.json'),
|
||||
JSON.stringify(data, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
console.log(JSON.stringify({ ok: true, outputDir, tools: registeredToolNames.length }, null, 2));
|
||||
}
|
||||
1178
src/server.ts
1178
src/server.ts
File diff suppressed because it is too large
Load Diff
132
src/tools/cleanup.ts
Normal file
132
src/tools/cleanup.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CleanupHistoryInput, CleanupProcessPlanInput } from '../types.js';
|
||||
import { defaultHistoryRoots } from '../core/constants.js';
|
||||
import { resolveInsideRoot, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
|
||||
export function createCleanupProcessPlan(input: CleanupProcessPlanInput): Record<string, unknown> {
|
||||
const project = resolveProject(input.project);
|
||||
const escapedProjectPath = project.path.replaceAll('\\', '\\\\');
|
||||
const portFilters = (input.ports || []).map((port) => ({
|
||||
command: `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`,
|
||||
port,
|
||||
}));
|
||||
|
||||
return {
|
||||
commands: [
|
||||
`Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escapedProjectPath}*' -and $_.Name -match 'node|pnpm|npm|yarn' } | Select-Object ProcessId,Name,CommandLine`,
|
||||
'确认命中进程只属于本次验证后,再用 Stop-Process -Id <pid> 结束。',
|
||||
...portFilters.map((item) => item.command),
|
||||
],
|
||||
notes: [
|
||||
'先按项目路径筛选,再按端口交叉确认。',
|
||||
'不要批量结束与本次验证无关的 Node 进程。',
|
||||
'页面测试结束后清理浏览器和 Vite/Nest 进程,避免内存累积。',
|
||||
],
|
||||
project,
|
||||
};
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
lastWriteTime: string;
|
||||
mtimeMs: number;
|
||||
name: string;
|
||||
path: string;
|
||||
relativePath: string;
|
||||
type: 'directory' | 'file';
|
||||
}
|
||||
|
||||
function listHistoryEntries(rootPath: string, preservedNames: Set<string>): HistoryEntry[] {
|
||||
if (!existsSync(rootPath)) return [];
|
||||
|
||||
return readdirSync(rootPath, { withFileTypes: true })
|
||||
.filter((item) => !preservedNames.has(item.name))
|
||||
.map((item) => {
|
||||
const entryPath = path.join(rootPath, item.name);
|
||||
const stats = statSync(entryPath);
|
||||
|
||||
return {
|
||||
lastWriteTime: stats.mtime.toISOString(),
|
||||
mtimeMs: stats.mtimeMs,
|
||||
name: item.name,
|
||||
path: entryPath,
|
||||
relativePath: toPosix(path.relative(workspaceRoot, entryPath)),
|
||||
type: item.isDirectory() ? ('directory' as const) : ('file' as const),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
}
|
||||
|
||||
export function cleanupHistoryArtifacts(input: CleanupHistoryInput = {}): Record<string, unknown> {
|
||||
const keep = Math.max(0, Math.min(input.keep ?? 3, 50));
|
||||
const dryRun = input.dryRun ?? true;
|
||||
const roots = (input.roots?.length ?? 0) > 0 ? input.roots! : [...defaultHistoryRoots];
|
||||
const result = {
|
||||
deleted: [] as Array<Omit<HistoryEntry, 'mtimeMs' | 'path'>>,
|
||||
dryRun,
|
||||
keep,
|
||||
preserved: [] as Array<Omit<HistoryEntry, 'mtimeMs' | 'path'>>,
|
||||
roots: [] as Array<{
|
||||
candidates: number;
|
||||
deleteCount: number;
|
||||
keepCount: number;
|
||||
preservedNames: string[];
|
||||
root: string;
|
||||
}>,
|
||||
skipped: [] as Array<{
|
||||
reason: string;
|
||||
root: string;
|
||||
}>,
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
const rootPath = resolveInsideRoot(root);
|
||||
const rootRelativePath = toPosix(path.relative(workspaceRoot, rootPath)) || '.';
|
||||
|
||||
if (!existsSync(rootPath)) {
|
||||
result.skipped.push({
|
||||
reason: 'not_exists',
|
||||
root: rootRelativePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const preservedNames = new Set(rootRelativePath === 'test-artifacts' ? ['_templates'] : []);
|
||||
const entries = listHistoryEntries(rootPath, preservedNames);
|
||||
const preserved = entries.slice(0, keep);
|
||||
const deleting = entries.slice(keep);
|
||||
|
||||
result.roots.push({
|
||||
candidates: entries.length,
|
||||
deleteCount: deleting.length,
|
||||
keepCount: preserved.length,
|
||||
preservedNames: Array.from(preservedNames),
|
||||
root: rootRelativePath,
|
||||
});
|
||||
result.preserved.push(...preserved.map(({ mtimeMs, path: _path, ...entry }) => entry));
|
||||
|
||||
for (const entry of deleting) {
|
||||
if (!dryRun) {
|
||||
rmSync(entry.path, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
const { mtimeMs, path: _path, ...publicEntry } = entry;
|
||||
result.deleted.push(publicEntry);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseCliCleanupArgs(args: string[]): CleanupHistoryInput {
|
||||
const keepArg = args.find((arg) => arg.startsWith('--keep='));
|
||||
const rootsArg = args.find((arg) => arg.startsWith('--roots='));
|
||||
|
||||
return {
|
||||
dryRun: args.includes('--dry-run'),
|
||||
keep: keepArg ? Number.parseInt(keepArg.split('=')[1], 10) : 3,
|
||||
roots: rootsArg ? rootsArg.split('=')[1].split(',').filter(Boolean) : [...defaultHistoryRoots],
|
||||
};
|
||||
}
|
||||
107
src/tools/envRisk.ts
Normal file
107
src/tools/envRisk.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { EnvPolicyInput, RiskScanInput } from '../types.js';
|
||||
import { tryExecFile } from '../core/exec.js';
|
||||
import { detectRepoType } from '../core/project.js';
|
||||
import { readTextIfExists, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
|
||||
export function buildEnvPolicy(input: EnvPolicyInput): Record<string, unknown> {
|
||||
const project = resolveProject(input.project);
|
||||
const envFiles = ['.env.development', '.env.production', '.env.example'].map((file) => {
|
||||
const absolutePath = path.join(project.path, file);
|
||||
return {
|
||||
exists: existsSync(absolutePath),
|
||||
file,
|
||||
relativePath: toPosix(path.relative(workspaceRoot, absolutePath)),
|
||||
};
|
||||
});
|
||||
const gitignore = readTextIfExists(path.join(project.path, '.gitignore'));
|
||||
const ignoredRules = gitignore
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
envFiles,
|
||||
ignoredRules: ignoredRules.filter((line) => line.includes('.env')),
|
||||
policy: [
|
||||
'真实开发和生产配置只保留在本地,不提交。',
|
||||
'.env.example 用于维护变量名和示例值。',
|
||||
'接口、数据库、WordPress、MinIO 等真实账号密码不得写入文档或提交记录。',
|
||||
],
|
||||
project,
|
||||
warnings: envFiles
|
||||
.filter((file) => file.file !== '.env.example' && file.exists)
|
||||
.map((file) => `${file.file} 存在,提交前确认已被 .gitignore 忽略。`),
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanTaskRisk(input: RiskScanInput): Promise<Record<string, unknown>> {
|
||||
const project = resolveProject(input.project);
|
||||
let changedFiles = input.changedFiles || [];
|
||||
|
||||
if (changedFiles.length === 0 && detectRepoType(project.path) === 'git') {
|
||||
const status = await tryExecFile('git', ['status', '--short'], project.path);
|
||||
if (status.ok) {
|
||||
changedFiles = status.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.slice(3).trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
const risks: Array<{
|
||||
file: string;
|
||||
level: 'high' | 'medium';
|
||||
reason: string;
|
||||
suggestion: string;
|
||||
}> = [];
|
||||
const addRisk = (
|
||||
level: 'high' | 'medium',
|
||||
file: string,
|
||||
reason: string,
|
||||
suggestion: string,
|
||||
) => {
|
||||
risks.push({ file, level, reason, suggestion });
|
||||
};
|
||||
|
||||
for (const file of changedFiles) {
|
||||
const normalized = file.replaceAll('\\', '/');
|
||||
|
||||
if (/\.env\.(development|production)$/.test(normalized)) {
|
||||
addRisk('high', file, '真实环境配置文件有改动。', '确认不会提交真实密钥和密码。');
|
||||
}
|
||||
|
||||
if (/package\.json$|pnpm-lock\.yaml$|package-lock\.json$|yarn\.lock$/.test(normalized)) {
|
||||
addRisk('medium', file, '依赖或脚本发生变化。', '确认锁文件与 package.json 同步,并运行对应轻量验证。');
|
||||
}
|
||||
|
||||
if (normalized.includes('/components/ktTable/')) {
|
||||
addRisk(
|
||||
'medium',
|
||||
file,
|
||||
'KtTable 是高频核心组件,布局和滚动性能容易回归。',
|
||||
'保持原生 antdv-next 表格能力,避免监听 scroll/resize 做高频状态更新。',
|
||||
);
|
||||
}
|
||||
|
||||
if (/src\/admin\/|src\/.*controller|src\/.*service|src\/.*dto/.test(normalized)) {
|
||||
addRisk('medium', file, '后端接口或业务逻辑改动。', '本地启动或复用服务后真实调用对应接口。');
|
||||
}
|
||||
|
||||
if (/Jenkinsfile|deploy\/|k8s\/|nginx.*\.conf/.test(normalized)) {
|
||||
addRisk('medium', file, '部署链路配置改动。', '只验证配置语法或生成产物,不主动触发远程部署。');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changedFiles,
|
||||
project,
|
||||
risks,
|
||||
safeDefaults: [
|
||||
'不覆盖用户已有改动。',
|
||||
'只运行必要验证。',
|
||||
'提交前重新检查 status 和 diff。',
|
||||
],
|
||||
};
|
||||
}
|
||||
75
src/tools/guardrails.ts
Normal file
75
src/tools/guardrails.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { GuardrailSet, GuardrailsInput, GuardrailsResult } from '../types.js';
|
||||
import { resolveProject } from '../core/workspace.js';
|
||||
import { createPageTestCase } from './testing.js';
|
||||
import { buildVerificationPlan } from './verification.js';
|
||||
export function buildGuardrails(input: GuardrailsInput): GuardrailsResult {
|
||||
const project = resolveProject(input.project);
|
||||
const taskType = input.taskType || input.changeType || 'general';
|
||||
const paths = input.paths || [];
|
||||
const guardrails: GuardrailSet = {
|
||||
beforeEdit: [
|
||||
'先读最近的 AGENTS.md,再读 TASKS.md 和 SKILLS.md。',
|
||||
'先检查目标仓库状态,识别用户已有改动,不覆盖、不回滚无关文件。',
|
||||
'先快速扫现有代码风格和目录结构,再决定改动方式。',
|
||||
'搜索优先用 rg,路径和接口名尽量保持可 grep。',
|
||||
],
|
||||
duringEdit: [
|
||||
'改动范围收敛到本次需求相关文件。',
|
||||
'复杂度高的地方加简短注释,避免空泛解释。',
|
||||
'真实环境配置不写入提交内容,优先只维护 .env.example。',
|
||||
'前端实现遵循现有 antdv-next/Vben 约定,不引入无关抽象。',
|
||||
],
|
||||
doNot: [
|
||||
'不要执行 git reset --hard、git checkout -- 等破坏性回滚。',
|
||||
'不要在用户未要求时自动 push。',
|
||||
'不要把真实密钥、数据库密码、生产 env 写进可提交文件。',
|
||||
'不要为了验证长期保留 Node/Vite/浏览器进程。',
|
||||
],
|
||||
verification: buildVerificationPlan({
|
||||
changeType: taskType,
|
||||
includePageTest: input.includePageTest || taskType === 'page',
|
||||
project: input.project,
|
||||
}),
|
||||
};
|
||||
|
||||
if (['api', 'backend'].includes(taskType)) {
|
||||
guardrails.beforeEdit.push('接口改动先确认 DTO、Controller、Service、返回结构和鉴权链路。');
|
||||
guardrails.duringEdit.push('接口成功返回统一保持 code=200/msg/data,错误返回 err 字段。');
|
||||
guardrails.verification.notes.push('接口改完必须本地真实调用一次对应接口。');
|
||||
}
|
||||
|
||||
guardrails.verification.notes.push(
|
||||
'改动后的 review 是硬步骤,优先用 kt_global_code_review 或 pnpm run global-review。',
|
||||
);
|
||||
|
||||
if (taskType === 'page' || input.includePageTest) {
|
||||
guardrails.beforeEdit.push('页面级测试先写用例,再打开可视化浏览器执行。');
|
||||
guardrails.verification.pageTestCase = createPageTestCase({
|
||||
project: input.project,
|
||||
title: input.userRequest || `${project.label} 页面级测试`,
|
||||
});
|
||||
}
|
||||
|
||||
if (taskType === 'database') {
|
||||
guardrails.beforeEdit.push('数据库同步或修复前先确认源库、目标库、备份库和回滚点。');
|
||||
guardrails.doNot.push('不要在未确认目标库前执行覆盖、drop、truncate。');
|
||||
}
|
||||
|
||||
if (taskType === 'deploy') {
|
||||
guardrails.duringEdit.push('部署配置保留真正能提升运行稳定性的内容,删掉噪音配置。');
|
||||
guardrails.doNot.push('不要在未要求时触发真实部署或改动远程服务。');
|
||||
}
|
||||
|
||||
if (taskType === 'mcp') {
|
||||
guardrails.duringEdit.push('MCP 工具描述要明确边界,默认输出建议和检查清单,不做高风险副作用。');
|
||||
guardrails.verification.notes.push('MCP 变更至少跑 self-test 和真实 SDK Client smoke test。');
|
||||
}
|
||||
|
||||
return {
|
||||
guardrails,
|
||||
paths,
|
||||
project,
|
||||
taskType,
|
||||
userRequest: input.userRequest || '',
|
||||
};
|
||||
}
|
||||
64
src/tools/inspect.ts
Normal file
64
src/tools/inspect.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { InspectAllProjectsInput, InspectProjectInput } from '../types.js';
|
||||
import { projectAliases } from '../core/constants.js';
|
||||
import { tryExecFile } from '../core/exec.js';
|
||||
import { detectPackageManager, detectRepoType } from '../core/project.js';
|
||||
import { listImmediateChildren, resolveProject, workspaceRoot } from '../core/workspace.js';
|
||||
export async function inspectProject(input: InspectProjectInput): Promise<Record<string, unknown>> {
|
||||
const project = resolveProject(input.project);
|
||||
const repoType = detectRepoType(project.path);
|
||||
const packageInfo = detectPackageManager(project.path);
|
||||
const nodeVersionFile = ['.node-version', '.nvmrc'].find((file) =>
|
||||
existsSync(path.join(project.path, file)),
|
||||
);
|
||||
const nodeVersion = nodeVersionFile
|
||||
? readFileSync(path.join(project.path, nodeVersionFile), 'utf8').trim()
|
||||
: null;
|
||||
const envFiles = ['.env.development', '.env.production', '.env.example'].map(
|
||||
(file) => ({
|
||||
exists: existsSync(path.join(project.path, file)),
|
||||
file,
|
||||
}),
|
||||
);
|
||||
const childSummary = listImmediateChildren(project.path).slice(0, 60);
|
||||
const git =
|
||||
repoType === 'git' && input.includeGit !== false
|
||||
? {
|
||||
branch: await tryExecFile('git', ['branch', '--show-current'], project.path),
|
||||
remote: await tryExecFile('git', ['remote', '-v'], project.path),
|
||||
status: await tryExecFile('git', ['status', '--short'], project.path),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
childSummary,
|
||||
envFiles,
|
||||
git,
|
||||
nodeVersion,
|
||||
nodeVersionFile: nodeVersionFile || null,
|
||||
packageInfo,
|
||||
project,
|
||||
repoType,
|
||||
};
|
||||
}
|
||||
|
||||
export async function inspectAllProjects(input: InspectAllProjectsInput = {}): Promise<Record<string, unknown>> {
|
||||
const aliases = input.includeRoot === false
|
||||
? Object.keys(projectAliases).filter((key) => key !== 'root')
|
||||
: Object.keys(projectAliases);
|
||||
const inspections = await Promise.all(
|
||||
aliases.map((alias) =>
|
||||
inspectProject({
|
||||
includeGit: input.includeGit !== false,
|
||||
project: alias,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
inspections,
|
||||
workspaceRoot,
|
||||
};
|
||||
}
|
||||
365
src/tools/review.ts
Normal file
365
src/tools/review.ts
Normal file
@ -0,0 +1,365 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { GlobalCodeReviewInput, ProjectAlias, RepoType, ResolvedProject } from '../types.js';
|
||||
import { tryExecFile } from '../core/exec.js';
|
||||
import { detectRepoType } from '../core/project.js';
|
||||
import { resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
|
||||
import { scanTaskRisk } from './envRisk.js';
|
||||
export const defaultReviewProjects = [
|
||||
'api',
|
||||
'admin',
|
||||
'blog',
|
||||
'web',
|
||||
'playground',
|
||||
'mcp',
|
||||
] as const;
|
||||
|
||||
const reviewSkipDirectoryNames = new Set([
|
||||
'.git',
|
||||
'.turbo',
|
||||
'.vite',
|
||||
'coverage',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
]);
|
||||
|
||||
const reviewSkipFilePattern =
|
||||
/(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|.*\.map|.*\.png|.*\.jpg|.*\.jpeg|.*\.gif|.*\.webp|.*\.zip|.*\.tar|.*\.gz)$/i;
|
||||
|
||||
const sensitiveTrackedFilePattern =
|
||||
/(^|\/)(\.env($|\..+)|deploy\.json|secrets?\.(env|json|ya?ml)|id_rsa|[^/]+\.(pem|key))$/i;
|
||||
|
||||
const allowedSensitiveTrackedFilePattern = /(^|\/)\.env\.example$/i;
|
||||
|
||||
const credentialAssignmentPattern =
|
||||
/\b(password|passwd|secret|token|webuiToken|accessToken|privateKey)\b\s*[:=]\s*(["'])([^"']+)\2/i;
|
||||
|
||||
interface ReviewFinding {
|
||||
category: string;
|
||||
file?: string;
|
||||
line?: number;
|
||||
level: 'P1' | 'P2' | 'P3';
|
||||
message: string;
|
||||
project: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
interface ReviewProjectResult {
|
||||
changedFiles: string[];
|
||||
credentialHints: ReviewFinding[];
|
||||
debugFindings: ReviewFinding[];
|
||||
findings: ReviewFinding[];
|
||||
project: ResolvedProject;
|
||||
repoType: RepoType;
|
||||
riskScan: Record<string, unknown>;
|
||||
status: string;
|
||||
trackedSensitiveFiles: string[];
|
||||
}
|
||||
|
||||
export function parseGitStatusFiles(status: string): string[] {
|
||||
return status
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const normalized = line.trimStart();
|
||||
const match = normalized.match(/^[ MADRCU?!]{1,2}\s+(.+)$/);
|
||||
const file = (match?.[1] || line.slice(3)).trim();
|
||||
return file.includes(' -> ') ? file.split(' -> ').at(-1)!.trim() : file;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function listGitTrackedFiles(projectPath: string): Promise<string[]> {
|
||||
const tracked = await tryExecFile('git', ['ls-files'], projectPath);
|
||||
if (!tracked.ok) return [];
|
||||
return tracked.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((file) => file.trim())
|
||||
.filter(Boolean)
|
||||
.map((file) => file.replaceAll('\\', '/'));
|
||||
}
|
||||
|
||||
function shouldReadReviewFile(file: string): boolean {
|
||||
const normalized = file.replaceAll('\\', '/');
|
||||
return !reviewSkipFilePattern.test(normalized);
|
||||
}
|
||||
|
||||
function readProjectFileLines(projectPath: string, file: string): string[] {
|
||||
const filePath = path.join(projectPath, file);
|
||||
if (!existsSync(filePath)) return [];
|
||||
|
||||
try {
|
||||
const stats = statSync(filePath);
|
||||
if (!stats.isFile() || stats.size > 1024 * 1024) return [];
|
||||
return readFileSync(filePath, 'utf8').split(/\r?\n/);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isBenignCredentialValue(value: string): boolean {
|
||||
const normalized = value.trim().replace(/^["']|["']$/g, '').toLowerCase();
|
||||
if (!normalized) return true;
|
||||
if (normalized.startsWith('$') || normalized.startsWith('${')) return true;
|
||||
if (normalized.startsWith('<') || normalized.includes('example')) return true;
|
||||
return [
|
||||
'false',
|
||||
'null',
|
||||
'password',
|
||||
'please-replace-me-with-your-own-key',
|
||||
'secret',
|
||||
'token',
|
||||
'true',
|
||||
'undefined',
|
||||
'your-token',
|
||||
].includes(normalized);
|
||||
}
|
||||
|
||||
function isRuntimeDebugAllowed(projectAlias: ProjectAlias | null, file: string): boolean {
|
||||
const normalized = file.replaceAll('\\', '/');
|
||||
if (/^(README|skills\/|.*\.md$)/.test(normalized)) return true;
|
||||
if (projectAlias === 'admin' && normalized.startsWith('internal/vite-config/')) {
|
||||
return true;
|
||||
}
|
||||
if (projectAlias === 'mcp' && /^(scripts\/|src\/server\.ts$)/.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isCredentialHintAllowed(file: string, line: string): boolean {
|
||||
const normalized = file.replaceAll('\\', '/');
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('<')) return true;
|
||||
return /(^|\/)views\/(demos|examples)\//.test(normalized);
|
||||
}
|
||||
|
||||
function collectProjectContentFindings(
|
||||
project: ResolvedProject,
|
||||
trackedFiles: string[],
|
||||
includeContentScan: boolean,
|
||||
maxFindings: number,
|
||||
): {
|
||||
credentialHints: ReviewFinding[];
|
||||
debugFindings: ReviewFinding[];
|
||||
findings: ReviewFinding[];
|
||||
} {
|
||||
const findings: ReviewFinding[] = [];
|
||||
const credentialHints: ReviewFinding[] = [];
|
||||
const debugFindings: ReviewFinding[] = [];
|
||||
|
||||
for (const file of trackedFiles.filter(shouldReadReviewFile)) {
|
||||
const lines = readProjectFileLines(project.path, file);
|
||||
if (lines.length === 0) continue;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (/^(<<<<<<<|=======|>>>>>>>) /.test(line)) {
|
||||
findings.push({
|
||||
category: 'conflict-marker',
|
||||
file,
|
||||
line: index + 1,
|
||||
level: 'P1',
|
||||
message: '发现 Git 冲突标记。',
|
||||
project: project.relativePath,
|
||||
suggestion: '先解决冲突标记,再继续构建或提交。',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isRuntimeDebugAllowed(project.alias, file) && /\bdebugger;|console\.log\(/.test(line)) {
|
||||
debugFindings.push({
|
||||
category: 'runtime-debug',
|
||||
file,
|
||||
line: index + 1,
|
||||
level: /\bdebugger;/.test(line) ? 'P2' : 'P3',
|
||||
message: '运行时代码中存在调试输出。',
|
||||
project: project.relativePath,
|
||||
suggestion: '页面或服务运行时代码不要保留 console.log/debugger。',
|
||||
});
|
||||
}
|
||||
|
||||
if (includeContentScan) {
|
||||
const credentialMatch = line.match(credentialAssignmentPattern);
|
||||
if (
|
||||
credentialMatch &&
|
||||
!allowedSensitiveTrackedFilePattern.test(file) &&
|
||||
!isCredentialHintAllowed(file, line) &&
|
||||
!isBenignCredentialValue(credentialMatch[3])
|
||||
) {
|
||||
credentialHints.push({
|
||||
category: 'credential-literal',
|
||||
file,
|
||||
line: index + 1,
|
||||
level: 'P2',
|
||||
message: `疑似 ${credentialMatch[1]} 字面量赋值。`,
|
||||
project: project.relativePath,
|
||||
suggestion: '确认是否为真实凭据;真实值应移入本地 env 或远程 Secret。',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
findings.length + credentialHints.length + debugFindings.length >=
|
||||
maxFindings * 3
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
credentialHints: credentialHints.slice(0, maxFindings),
|
||||
debugFindings: debugFindings.slice(0, maxFindings),
|
||||
findings: findings.slice(0, maxFindings),
|
||||
};
|
||||
}
|
||||
|
||||
function findWorkspaceFilesByName(names: Set<string>, maxResults: number): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
const walk = (currentPath: string) => {
|
||||
if (results.length >= maxResults) return;
|
||||
|
||||
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
|
||||
if (results.length >= maxResults) return;
|
||||
if (entry.isDirectory()) {
|
||||
if (reviewSkipDirectoryNames.has(entry.name)) continue;
|
||||
walk(path.join(currentPath, entry.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && names.has(entry.name)) {
|
||||
results.push(toPosix(path.relative(workspaceRoot, path.join(currentPath, entry.name))));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(workspaceRoot);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function reviewProject(
|
||||
projectKey: string,
|
||||
input: Required<Pick<GlobalCodeReviewInput, 'includeContentScan' | 'maxFindingsPerProject'>>,
|
||||
): Promise<ReviewProjectResult> {
|
||||
const project = resolveProject(projectKey);
|
||||
const repoType = detectRepoType(project.path);
|
||||
const status =
|
||||
repoType === 'git'
|
||||
? await tryExecFile('git', ['status', '--short'], project.path)
|
||||
: { ok: false, stderr: '', stdout: '' };
|
||||
const changedFiles = status.ok ? parseGitStatusFiles(status.stdout) : [];
|
||||
const trackedFiles = repoType === 'git' ? await listGitTrackedFiles(project.path) : [];
|
||||
const trackedSensitiveFiles = trackedFiles.filter(
|
||||
(file) =>
|
||||
sensitiveTrackedFilePattern.test(file) &&
|
||||
!allowedSensitiveTrackedFilePattern.test(file),
|
||||
);
|
||||
const contentFindings = collectProjectContentFindings(
|
||||
project,
|
||||
trackedFiles,
|
||||
input.includeContentScan,
|
||||
input.maxFindingsPerProject,
|
||||
);
|
||||
const trackedSensitiveFindings = trackedSensitiveFiles
|
||||
.slice(0, input.maxFindingsPerProject)
|
||||
.map<ReviewFinding>((file) => ({
|
||||
category: 'tracked-sensitive-file',
|
||||
file,
|
||||
level: 'P1',
|
||||
message: '仓库仍跟踪真实环境或部署凭据类文件。',
|
||||
project: project.relativePath,
|
||||
suggestion: '使用 git rm --cached 移出索引,并提交 example 文件或文档说明。',
|
||||
}));
|
||||
|
||||
return {
|
||||
changedFiles,
|
||||
credentialHints: contentFindings.credentialHints,
|
||||
debugFindings: contentFindings.debugFindings,
|
||||
findings: [...trackedSensitiveFindings, ...contentFindings.findings],
|
||||
project,
|
||||
repoType,
|
||||
riskScan: await scanTaskRisk({
|
||||
changedFiles,
|
||||
project: projectKey,
|
||||
}),
|
||||
status: status.ok ? status.stdout : status.stderr,
|
||||
trackedSensitiveFiles,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildGlobalCodeReview(
|
||||
input: GlobalCodeReviewInput = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const projects =
|
||||
(input.projects?.length ?? 0) > 0
|
||||
? input.projects!
|
||||
: [...defaultReviewProjects];
|
||||
const includeContentScan = input.includeContentScan ?? true;
|
||||
const maxFindingsPerProject = Math.max(
|
||||
1,
|
||||
Math.min(input.maxFindingsPerProject ?? 20, 200),
|
||||
);
|
||||
const projectResults = await Promise.all(
|
||||
projects.map((project) =>
|
||||
reviewProject(project, {
|
||||
includeContentScan,
|
||||
maxFindingsPerProject,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const rootLegacyFiles =
|
||||
input.includeRootScan === false
|
||||
? []
|
||||
: findWorkspaceFilesByName(new Set(['deploy.js', 'deploy.json']), 50);
|
||||
const findings = projectResults.flatMap((item) => [
|
||||
...item.findings,
|
||||
...item.debugFindings,
|
||||
...item.credentialHints,
|
||||
]);
|
||||
const rootFindings = rootLegacyFiles.map<ReviewFinding>((file) => ({
|
||||
category: 'legacy-deploy-file',
|
||||
file,
|
||||
level: 'P2',
|
||||
message: '发现旧部署遗留文件。',
|
||||
project: 'root',
|
||||
suggestion: '确认是否仍被脚本引用;无用则删除并从 Git 索引移除。',
|
||||
}));
|
||||
const allFindings = [...findings, ...rootFindings];
|
||||
|
||||
return {
|
||||
findings: allFindings,
|
||||
generatedAt: new Date().toISOString(),
|
||||
notes: [
|
||||
'本工具只读扫描,不会修改文件、删除文件或提交代码。',
|
||||
'P1 优先处理:冲突标记、被跟踪的真实 env/部署凭据文件。',
|
||||
'P2 需要人工确认:疑似凭据字面量、旧部署文件、调试断点。',
|
||||
'P3 为清洁度问题:运行时 console.log 等。',
|
||||
],
|
||||
projects: projectResults.map((item) => ({
|
||||
changedFiles: item.changedFiles,
|
||||
credentialHintCount: item.credentialHints.length,
|
||||
debugFindingCount: item.debugFindings.length,
|
||||
findingCount: item.findings.length,
|
||||
project: item.project,
|
||||
repoType: item.repoType,
|
||||
riskScan: item.riskScan,
|
||||
status: item.status,
|
||||
trackedSensitiveFiles: item.trackedSensitiveFiles,
|
||||
})),
|
||||
summary: {
|
||||
changedProjectCount: projectResults.filter((item) => item.changedFiles.length > 0)
|
||||
.length,
|
||||
findingCount: allFindings.length,
|
||||
p1: allFindings.filter((item) => item.level === 'P1').length,
|
||||
p2: allFindings.filter((item) => item.level === 'P2').length,
|
||||
p3: allFindings.filter((item) => item.level === 'P3').length,
|
||||
reviewedProjects: projectResults.length,
|
||||
rootLegacyFiles,
|
||||
},
|
||||
workspaceRoot,
|
||||
};
|
||||
}
|
||||
146
src/tools/task.ts
Normal file
146
src/tools/task.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import type { CommitChecklistInput, PrepareTaskInput, TaskRecordInput, WorkflowContextInput } from '../types.js';
|
||||
import { projectAliases } from '../core/constants.js';
|
||||
import { detectRepoType } from '../core/project.js';
|
||||
import { formatDateInShanghai, getRecentTaskRecords, readText, resolveInsideRoot, resolveProject, workspaceRoot } from '../core/workspace.js';
|
||||
import { buildGuardrails } from './guardrails.js';
|
||||
import { inspectProject } from './inspect.js';
|
||||
import { scanTaskRisk } from './envRisk.js';
|
||||
import { createPageTestCase } from './testing.js';
|
||||
export async function prepareTask(input: PrepareTaskInput): Promise<Record<string, unknown>> {
|
||||
const project = resolveProject(input.project);
|
||||
const taskType = input.taskType || 'general';
|
||||
const inspection = await inspectProject({
|
||||
includeGit: input.includeGit !== false,
|
||||
project: input.project,
|
||||
});
|
||||
const guardrails = buildGuardrails(input);
|
||||
const riskScan = await scanTaskRisk({
|
||||
changedFiles: input.changedFiles || [],
|
||||
project: input.project,
|
||||
});
|
||||
const pageTestCase =
|
||||
input.includePageTest || taskType === 'page'
|
||||
? createPageTestCase({
|
||||
entryUrl: input.entryUrl,
|
||||
project: input.project,
|
||||
title: input.userRequest || `${project.label} 页面级测试`,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
guardrails,
|
||||
inspection,
|
||||
nextSteps: [
|
||||
'阅读 workPacket.readFirst 中的上下文文件。',
|
||||
'按 guardrails.beforeEdit 做开工前检查。',
|
||||
'按最小范围修改。',
|
||||
'按 verification.commands 和 notes 做轻量验证。',
|
||||
'有文件改动时更新 TASKS.md。',
|
||||
'最终回复或提交前跑全局 review,并处理全部 findings。',
|
||||
],
|
||||
pageTestCase,
|
||||
project,
|
||||
readFirst: [
|
||||
'AGENTS.md',
|
||||
'SKILLS.md',
|
||||
'TASKS.md',
|
||||
`${project.relativePath}/AGENTS.md`,
|
||||
`${project.relativePath}/package.json`,
|
||||
],
|
||||
riskScan,
|
||||
taskRecordDraft: buildTaskRecord({
|
||||
content: input.userRequest || '待补充',
|
||||
scope: [project.relativePath],
|
||||
testCase: pageTestCase ? pageTestCase.title : '本次无页面测试或待补充。',
|
||||
title: input.recordTitle || input.userRequest || `${project.label} 任务记录`,
|
||||
verification: guardrails.guardrails.verification.commands.join(';') || '待补充。',
|
||||
}),
|
||||
taskType,
|
||||
userRequest: input.userRequest || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTaskRecord(input: TaskRecordInput): string {
|
||||
const date = input.date || formatDateInShanghai();
|
||||
const scope = (input.scope?.length ?? 0) > 0 ? input.scope!.join('、') : '待补充';
|
||||
const content = input.content || '待补充';
|
||||
const testCase = input.testCase || '本次无页面测试或待补充。';
|
||||
const verification = input.verification || '待补充。';
|
||||
|
||||
return `### ${date}:${input.title}\n\n- 范围:${scope}\n- 内容:${content}\n- 页面测试用例:${testCase}\n- 验证:${verification}\n`;
|
||||
}
|
||||
|
||||
export function appendTaskRecord(input: TaskRecordInput): Record<string, unknown> {
|
||||
const record = buildTaskRecord(input);
|
||||
const tasksPath = resolveInsideRoot('TASKS.md');
|
||||
const current = readFileSync(tasksPath, 'utf8');
|
||||
const marker = '## 改动记录';
|
||||
const markerIndex = current.indexOf(marker);
|
||||
|
||||
if (markerIndex === -1) {
|
||||
throw new Error('TASKS.md does not contain ## 改动记录');
|
||||
}
|
||||
|
||||
const insertAt = current.indexOf('\n', markerIndex);
|
||||
const next = `${current.slice(0, insertAt + 1)}\n${record}\n${current.slice(insertAt + 1)}`;
|
||||
|
||||
if (!input.dryRun) {
|
||||
writeFileSync(tasksPath, next, 'utf8');
|
||||
}
|
||||
|
||||
return {
|
||||
dryRun: input.dryRun,
|
||||
record,
|
||||
target: tasksPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createCommitChecklist(input: CommitChecklistInput): Promise<Record<string, unknown>> {
|
||||
const inspection = await inspectProject({
|
||||
includeGit: true,
|
||||
project: input.project,
|
||||
});
|
||||
const message = input.message || '';
|
||||
const messageOk =
|
||||
!message ||
|
||||
/^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?: .*[一-龥]/u.test(
|
||||
message,
|
||||
);
|
||||
|
||||
return {
|
||||
checks: [
|
||||
'确认只包含本次任务相关文件。',
|
||||
'先运行对应轻量验证命令。',
|
||||
'提交前已跑全局 review,且 findings 已修复或明确记录为非阻塞风险。',
|
||||
'每个独立 Git 仓库单独 commit。',
|
||||
'没有用户明确要求时不 push。',
|
||||
],
|
||||
inspection,
|
||||
message,
|
||||
messageOk,
|
||||
messageRule: '英文 type 前缀 + 中文描述,例如 feat: 功能性提交',
|
||||
};
|
||||
}
|
||||
|
||||
export function readWorkflowContext(input: WorkflowContextInput): Record<string, unknown> {
|
||||
const projects = Object.keys(projectAliases).map((key) => {
|
||||
const project = resolveProject(key);
|
||||
return {
|
||||
...project,
|
||||
exists: existsSync(project.path),
|
||||
repoType: existsSync(project.path) ? detectRepoType(project.path) : 'missing',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
docs: {
|
||||
agents: readText('AGENTS.md'),
|
||||
recentTasks: getRecentTaskRecords(input.taskRecordCount),
|
||||
skills: readText('SKILLS.md'),
|
||||
},
|
||||
projects,
|
||||
workspaceRoot,
|
||||
};
|
||||
}
|
||||
155
src/tools/testing.ts
Normal file
155
src/tools/testing.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import type { ApiTestPlanInput, BusinessFlow, BusinessTestPlanInput, PageTestCaseInput, PageTestCaseResult } from '../types.js';
|
||||
import { resolveProject } from '../core/workspace.js';
|
||||
export function createPageTestCase(input: PageTestCaseInput): PageTestCaseResult {
|
||||
const project = resolveProject(input.project);
|
||||
const title = input.title || `${project.label} 页面级测试`;
|
||||
const steps =
|
||||
(input.steps?.length ?? 0) > 0
|
||||
? input.steps!
|
||||
: ['打开入口 URL', '完成登录或注入测试登录态', '执行用户关键路径操作', '保存关键步骤截图'];
|
||||
const assertions =
|
||||
(input.assertions?.length ?? 0) > 0
|
||||
? input.assertions!
|
||||
: ['页面无明显渲染错误', '控制台无 error', '关键接口返回成功', '核心 DOM 或业务状态符合预期'];
|
||||
|
||||
return {
|
||||
account: input.account || '按当前环境使用数据库管理员账号或测试专用账号',
|
||||
assertions,
|
||||
cleanup: [
|
||||
'关闭浏览器实例',
|
||||
'清理本次启动的 Node/Vite 进程',
|
||||
'保存截图和 result.json',
|
||||
'调用 kt_cleanup_history,仅保留最近 3 轮历史产物',
|
||||
],
|
||||
entryUrl: input.entryUrl || '待填写',
|
||||
maxRounds: 3,
|
||||
project,
|
||||
protocol: [
|
||||
'测试前先写用例。',
|
||||
'失败后先记录复现证据,再排查和修复。',
|
||||
'按同一用例复测。',
|
||||
'第三轮仍失败时停止并等待用户建议。',
|
||||
],
|
||||
steps,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
export function createApiTestPlan(input: ApiTestPlanInput): Record<string, unknown> {
|
||||
const project = resolveProject(input.project || 'api');
|
||||
const method = (input.method || 'GET').toUpperCase();
|
||||
const endpoint = input.endpoint || '/api/待填写';
|
||||
const baseUrl = input.baseUrl || 'http://localhost:3000';
|
||||
const headers = [];
|
||||
|
||||
if (input.auth === 'bearer') {
|
||||
headers.push('-H "Authorization: Bearer <accessToken>"');
|
||||
}
|
||||
|
||||
if (input.contentType !== false && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
headers.push('-H "Content-Type: application/json"');
|
||||
}
|
||||
|
||||
const body =
|
||||
input.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)
|
||||
? ` -d '${JSON.stringify(input.body)}'`
|
||||
: '';
|
||||
const curl = `curl -i -X ${method} ${headers.join(' ')} "${baseUrl}${endpoint}"${body}`;
|
||||
|
||||
return {
|
||||
assertions: [
|
||||
'HTTP 状态码符合预期。',
|
||||
'成功响应结构为 { code: 200, msg: string, data: any }。',
|
||||
'成功响应不返回 err 字段。',
|
||||
...(input.expectedFields || []).map((field) => `data 中包含 ${field}`),
|
||||
],
|
||||
curl,
|
||||
endpoint,
|
||||
method,
|
||||
notes: [
|
||||
'接口改动后不要只跑类型检查,必须真实调用对应接口一次。',
|
||||
'如依赖登录态,先调用 login 获取 token,再带 Authorization 复测。',
|
||||
],
|
||||
project,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBusinessTestPlan(input: BusinessTestPlanInput = {}): Record<string, unknown> {
|
||||
const environment = input.environment || 'local';
|
||||
const baseUrl = input.baseUrl || (environment === 'remote' ? 'https://待填写' : 'http://127.0.0.1');
|
||||
const flows: Record<BusinessFlow, Record<string, unknown>> = {
|
||||
'admin-login': {
|
||||
assertions: [
|
||||
'登录接口返回 code=200 且 data 内有 accessToken/userInfo/menu/permissions。',
|
||||
'Admin 页面进入后后端路由菜单正常渲染。',
|
||||
'WordPress/NAS 不可用时不阻塞 Admin 登录。',
|
||||
],
|
||||
preflight: ['API 可达', 'Admin dev server 可达', '数据库管理员账号存在'],
|
||||
projects: ['api', 'admin'],
|
||||
steps: [
|
||||
'调用 /auth/login 或使用 admin-login 脚本固化登录态。',
|
||||
'进入 Admin 首页,确认菜单与按钮权限。',
|
||||
'刷新页面后确认 auth 持久化仍有效。',
|
||||
],
|
||||
},
|
||||
'blog-crud': {
|
||||
assertions: [
|
||||
'文章、分类、标签列表返回统一 code/msg/data。',
|
||||
'新增/编辑/删除后后端刷新 WordPress 缓存。',
|
||||
'文章编辑能绑定分类和标签,查询不额外打开新 tab。',
|
||||
],
|
||||
preflight: ['API 可达', 'WordPress 管理端认证可用或降级逻辑明确', 'Admin 博客菜单有权限'],
|
||||
projects: ['api', 'admin', 'blog'],
|
||||
steps: ['登录 Admin', '新增分类/标签', '新增文章并绑定分类标签', '编辑后查询列表', '清理 KT_TEST_ 数据'],
|
||||
},
|
||||
'qqbot-account-scan': {
|
||||
assertions: [
|
||||
'扫码创建返回 qrcode/containerName/webuiPort。',
|
||||
'取消/过期/失败会清理未绑定 NapCat 容器。',
|
||||
'删除账号会二次确认并删除专属容器。',
|
||||
],
|
||||
preflight: ['NAS SSH 可达', 'NapCat WebUI 可达', 'API QQBot env 配置完整'],
|
||||
projects: ['api', 'admin'],
|
||||
steps: ['打开账号连接页', '点击扫码新增账号', '轮询二维码状态', '确认账号回填', '测试删除联动容器'],
|
||||
},
|
||||
'qqbot-auto-reply': {
|
||||
assertions: [
|
||||
'消息日志能看到 OneBot 收到的私聊/群聊/频道消息。',
|
||||
'权限名单按 QQ号、群/频道、精确 QQ 过滤生效。',
|
||||
'规则命中后发送日志记录成功或失败原因。',
|
||||
],
|
||||
preflight: ['账号在线', 'MQTT 或 local bus 正常', '规则启用', '名单配置符合测试目标'],
|
||||
projects: ['api', 'admin'],
|
||||
steps: ['创建 KT_TEST_ 自动回复规则', '发送私聊测试消息', '查询消息日志', '查询发送日志', '清理测试规则'],
|
||||
},
|
||||
'web-playground-auth': {
|
||||
assertions: [
|
||||
'无 token 时不主动 refresh,不产生登录循环。',
|
||||
'Admin 已登录时能带回授权态并重定向回原页面。',
|
||||
'Playground 保存组件时截图上传 MinIO 并把 image 写入 API。',
|
||||
],
|
||||
preflight: ['Admin/API/Web/Playground 地址可达', '登录态存储为空或可控', 'MinIO 可达'],
|
||||
projects: ['api', 'admin', 'web', 'playground'],
|
||||
steps: ['清理本地 token', '从 Web/Playground 进入受保护页面', '跳 Admin 登录', '登录后回跳', '保存组件并查询接口'],
|
||||
},
|
||||
};
|
||||
const selected: Array<[string, Record<string, unknown>]> =
|
||||
!input.flow || input.flow === 'all'
|
||||
? Object.entries(flows)
|
||||
: [[input.flow, flows[input.flow] as Record<string, unknown>]];
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
environment,
|
||||
maxRounds: 3,
|
||||
protocol: [
|
||||
'业务测试先写用例,再按环境 -> 账号 -> 数据 -> 接口 -> 页面预检。',
|
||||
'失败必须记录复现步骤、证据和初步归因。',
|
||||
'同一用例最多三轮,第三轮仍失败则停止等待建议。',
|
||||
],
|
||||
selectedFlows: selected.map(([name, plan]) => ({
|
||||
name,
|
||||
...plan,
|
||||
})),
|
||||
};
|
||||
}
|
||||
76
src/tools/verification.ts
Normal file
76
src/tools/verification.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { VerificationPlanInput, VerificationPlanResult } from '../types.js';
|
||||
import { detectPackageManager, detectRepoType, getTypecheckCommand } from '../core/project.js';
|
||||
import { getGlobalReviewCommand, resolveProject } from '../core/workspace.js';
|
||||
export function buildVerificationPlan(input: VerificationPlanInput): VerificationPlanResult {
|
||||
const project = resolveProject(input.project);
|
||||
const repoType = detectRepoType(project.path);
|
||||
const packageInfo = detectPackageManager(project.path);
|
||||
const packageManager = packageInfo.packageManager || 'pnpm';
|
||||
const scripts = packageInfo.scripts;
|
||||
const changeType = input.changeType || 'general';
|
||||
const commands = repoType === 'git' ? ['git status --short'] : [];
|
||||
const notes = [
|
||||
'先确认仓库类型,再确认包管理器和 Node 版本。',
|
||||
'只跑当前变更所需的最轻验证,不默认全量 build。',
|
||||
'只要产生文件改动,最终回复或提交前必须走一次 review;有 findings 先修复再复跑。',
|
||||
];
|
||||
|
||||
const typecheck = getTypecheckCommand(project, packageManager, scripts);
|
||||
|
||||
if (changeType === 'style' && project.alias === 'admin') {
|
||||
commands.push(
|
||||
'pnpm exec stylelint "apps/web-antdv-next/src/components/**/*.{scss,css,vue}"',
|
||||
);
|
||||
}
|
||||
|
||||
if (repoType === 'none') {
|
||||
notes.push('当前目录不是独立 Git/SVN 仓库,提交和 diff 检查需要切到对应子仓库执行。');
|
||||
}
|
||||
|
||||
if (
|
||||
typecheck &&
|
||||
['api', 'frontend', 'general', 'mcp', 'page', 'style'].includes(changeType)
|
||||
) {
|
||||
commands.push(typecheck);
|
||||
}
|
||||
|
||||
if (project.alias === 'api') {
|
||||
if (scripts.lint) commands.push(`${packageManager} run lint`);
|
||||
if (scripts.test && ['api', 'backend', 'general'].includes(changeType)) {
|
||||
commands.push(`${packageManager} test -- --passWithNoTests`);
|
||||
}
|
||||
if (['api', 'backend'].includes(changeType)) {
|
||||
notes.push('接口改动需要本地启动或复用本地服务,真实调用对应接口一次。');
|
||||
}
|
||||
}
|
||||
|
||||
if (project.alias === 'playground' && scripts['build-preview'] && changeType === 'deploy') {
|
||||
commands.push(`${packageManager} run build-preview`);
|
||||
}
|
||||
|
||||
if (project.alias === 'web' && scripts.build && changeType === 'deploy') {
|
||||
commands.push(`${packageManager} run build`);
|
||||
}
|
||||
|
||||
if (project.alias === 'mcp') {
|
||||
if (scripts['self-test']) commands.push(`${packageManager} run self-test`);
|
||||
notes.push('MCP 变更需要用真实 SDK Client 做 listTools/callTool smoke test。');
|
||||
}
|
||||
|
||||
if (input.includePageTest || changeType === 'page') {
|
||||
notes.push(
|
||||
'页面级测试必须先写测试用例,使用可见浏览器或截图记录关键步骤,同一用例最多三轮闭环。',
|
||||
);
|
||||
}
|
||||
|
||||
if (repoType === 'git') commands.push('git diff --check');
|
||||
commands.push(getGlobalReviewCommand());
|
||||
|
||||
return {
|
||||
changeType,
|
||||
commands: Array.from(new Set(commands)),
|
||||
notes,
|
||||
project,
|
||||
repoType,
|
||||
};
|
||||
}
|
||||
316
src/tools/workflow.ts
Normal file
316
src/tools/workflow.ts
Normal file
@ -0,0 +1,316 @@
|
||||
import type { CommitPlanInput, ComponentWorkflowInput, DbSyncPlanInput, ExecResult, FinishTaskInput, PushPlanInput, RemoteHealthCheckInput, RepoType, ResolvedProject } from '../types.js';
|
||||
import { tryExecFile, tryPowerShell } from '../core/exec.js';
|
||||
import { detectRepoType } from '../core/project.js';
|
||||
import { formatDateInShanghai, getGlobalReviewCommand, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
|
||||
import { cleanupHistoryArtifacts } from './cleanup.js';
|
||||
import { buildGlobalCodeReview, defaultReviewProjects, parseGitStatusFiles } from './review.js';
|
||||
import { buildVerificationPlan } from './verification.js';
|
||||
interface RepoSnapshot {
|
||||
branch: string;
|
||||
changedFiles: string[];
|
||||
project: ResolvedProject;
|
||||
remote: string;
|
||||
repoType: RepoType;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function normalizeProjectKeys(projects?: string[]): string[] {
|
||||
const selected = (projects?.length ?? 0) > 0 ? projects! : [...defaultReviewProjects];
|
||||
return Array.from(new Set(selected.filter((project) => project !== 'root')));
|
||||
}
|
||||
|
||||
async function collectRepoSnapshot(projectKey: string): Promise<RepoSnapshot> {
|
||||
const project = resolveProject(projectKey);
|
||||
const repoType = detectRepoType(project.path);
|
||||
const status = repoType === 'git'
|
||||
? await tryExecFile('git', ['status', '--short'], project.path)
|
||||
: null;
|
||||
const branch = repoType === 'git'
|
||||
? await tryExecFile('git', ['branch', '--show-current'], project.path)
|
||||
: null;
|
||||
const remote = repoType === 'git'
|
||||
? await tryExecFile('git', ['remote', '-v'], project.path)
|
||||
: null;
|
||||
|
||||
return {
|
||||
branch: branch?.ok ? branch.stdout : '',
|
||||
changedFiles: status?.ok ? parseGitStatusFiles(status.stdout) : [],
|
||||
project,
|
||||
remote: remote?.ok ? remote.stdout : '',
|
||||
repoType,
|
||||
status: status?.ok ? status.stdout : status?.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
function suggestCommitMessage(snapshot: RepoSnapshot): string {
|
||||
const files = snapshot.changedFiles.join('\n');
|
||||
|
||||
if (snapshot.project.alias === 'mcp') {
|
||||
return 'feat(workflow): 扩展KT工作流能力';
|
||||
}
|
||||
if (snapshot.project.alias === 'api' && /qqbot/i.test(files)) {
|
||||
return 'fix(qqbot): 修复QQBot运行链路';
|
||||
}
|
||||
if (snapshot.project.alias === 'admin' && /\.env|authentication/.test(files)) {
|
||||
return 'fix(admin): 收敛环境配置和登录页';
|
||||
}
|
||||
if (snapshot.project.alias === 'web' && /deploy\.json|\.gitignore/.test(files)) {
|
||||
return 'chore(web): 清理旧部署配置';
|
||||
}
|
||||
if (snapshot.project.alias === 'blog') {
|
||||
return 'fix(blog): 优化博客前台体验';
|
||||
}
|
||||
return `chore(${snapshot.project.alias || 'repo'}): 更新项目改动`;
|
||||
}
|
||||
|
||||
export async function buildFinishTask(input: FinishTaskInput = {}): Promise<Record<string, unknown>> {
|
||||
const projectKeys = normalizeProjectKeys(input.projects);
|
||||
const snapshots = await Promise.all(projectKeys.map(collectRepoSnapshot));
|
||||
const changedSnapshots = snapshots.filter((snapshot) => snapshot.changedFiles.length > 0);
|
||||
const verificationPlans = changedSnapshots.map((snapshot) =>
|
||||
buildVerificationPlan({
|
||||
changeType: input.changeType || 'general',
|
||||
project: snapshot.project.alias || snapshot.project.relativePath,
|
||||
}),
|
||||
);
|
||||
const validationRuns: Array<{
|
||||
command: string;
|
||||
project: ResolvedProject;
|
||||
result: ExecResult;
|
||||
}> = [];
|
||||
|
||||
if (input.runValidation) {
|
||||
for (const plan of verificationPlans) {
|
||||
for (const command of plan.commands.filter((item) => !item.includes('global-review'))) {
|
||||
validationRuns.push({
|
||||
command,
|
||||
project: plan.project,
|
||||
result: await tryPowerShell(command, plan.project.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const review = await buildGlobalCodeReview({
|
||||
includeContentScan: true,
|
||||
includeRootScan: input.includeRootScan ?? true,
|
||||
maxFindingsPerProject: input.maxFindingsPerProject ?? 20,
|
||||
projects: projectKeys,
|
||||
});
|
||||
const cleanup = cleanupHistoryArtifacts({
|
||||
dryRun: !(input.cleanupHistory ?? false),
|
||||
keep: input.keepHistory ?? 3,
|
||||
});
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
mode: input.runValidation ? 'validation_executed' : 'plan_only',
|
||||
notes: [
|
||||
'默认只生成收尾结果;runValidation=true 时才执行验证命令。',
|
||||
'cleanupHistory=true 时才真实清理历史产物,否则仅预览。',
|
||||
'提交和推送仍需用户明确要求后执行。',
|
||||
],
|
||||
review,
|
||||
snapshots,
|
||||
stopConditions: [
|
||||
'review.findings 非空时先修复,再复跑 kt_global_code_review。',
|
||||
'validationRuns 中任一 result.ok=false 时先处理失败命令。',
|
||||
'发现真实 env、生产 Secret 或非本次范围文件时停止提交。',
|
||||
],
|
||||
summary: {
|
||||
changedProjectCount: changedSnapshots.length,
|
||||
changedProjects: changedSnapshots.map((snapshot) => snapshot.project.relativePath),
|
||||
validationRunCount: validationRuns.length,
|
||||
},
|
||||
validationRuns,
|
||||
verificationPlans,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildCommitPlan(input: CommitPlanInput = {}): Promise<Record<string, unknown>> {
|
||||
const snapshots = await Promise.all(normalizeProjectKeys(input.projects).map(collectRepoSnapshot));
|
||||
const changedSnapshots = snapshots.filter((snapshot) => snapshot.changedFiles.length > 0);
|
||||
|
||||
return {
|
||||
checks: [
|
||||
'每个独立 Git 仓库单独提交。',
|
||||
'提交前先跑对应轻量验证和 kt_global_code_review。',
|
||||
'确认 .env.development/.env.production 等真实配置只从索引移除,不提交真实值。',
|
||||
'根目录 AGENTS/SKILLS/TASKS 不属于子仓库,最终回复里单独说明。',
|
||||
],
|
||||
includePush: input.includePush ?? false,
|
||||
projects: changedSnapshots.map((snapshot) => {
|
||||
const message = input.message || suggestCommitMessage(snapshot);
|
||||
return {
|
||||
branch: snapshot.branch,
|
||||
changedFiles: snapshot.changedFiles,
|
||||
commands: [
|
||||
'git status --short',
|
||||
'git add -A',
|
||||
`git commit -m "${message}"`,
|
||||
...(input.includePush ? [`git push origin ${snapshot.branch || 'main'}`] : []),
|
||||
],
|
||||
message,
|
||||
project: snapshot.project,
|
||||
status: snapshot.status,
|
||||
};
|
||||
}),
|
||||
reviewCommand: getGlobalReviewCommand(),
|
||||
summary: {
|
||||
changedProjectCount: changedSnapshots.length,
|
||||
totalChangedFiles: changedSnapshots.reduce(
|
||||
(count, snapshot) => count + snapshot.changedFiles.length,
|
||||
0,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildPushPlan(input: PushPlanInput = {}): Promise<Record<string, unknown>> {
|
||||
const snapshots = await Promise.all(normalizeProjectKeys(input.projects).map(collectRepoSnapshot));
|
||||
const remote = input.remote || 'origin';
|
||||
|
||||
return {
|
||||
checks: [
|
||||
'推送前工作区应干净;如仍有改动,先提交或说明为什么暂不提交。',
|
||||
'推送前复跑 kt_global_code_review,findings 必须为 0 或明确非阻塞。',
|
||||
'如果 Jenkins/Gitea host key、mirror、hooks 异常,先修远程仓库状态再重试。',
|
||||
],
|
||||
projects: snapshots
|
||||
.filter((snapshot) => snapshot.repoType === 'git')
|
||||
.map((snapshot) => {
|
||||
const branch = input.branch || snapshot.branch || 'main';
|
||||
return {
|
||||
branch,
|
||||
commands: [
|
||||
'git status --short',
|
||||
`git log --oneline ${remote}/${branch}..${branch}`,
|
||||
`git push ${remote} ${branch}`,
|
||||
],
|
||||
project: snapshot.project,
|
||||
remote: snapshot.remote,
|
||||
status: snapshot.status,
|
||||
};
|
||||
}),
|
||||
reviewCommand: getGlobalReviewCommand(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRemoteHealthCommands(input: RemoteHealthCheckInput = {}): Array<{
|
||||
command: string;
|
||||
service: string;
|
||||
}> {
|
||||
const host = input.sshTarget || input.host || process.env.KT_NAS_SSH_TARGET || 'nas.kwitsukasa.top';
|
||||
const port = input.port ?? Number(process.env.KT_NAS_SSH_PORT || 2202);
|
||||
const ssh = (remoteCommand: string) => `ssh -p ${port} ${host} "${remoteCommand}"`;
|
||||
const allCommands: Record<string, string> = {
|
||||
docker: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}'"),
|
||||
gitea: ssh("docker ps --format '{{.Names}} {{.Status}}' | grep -i gitea || true"),
|
||||
jenkins: ssh("docker ps --format '{{.Names}} {{.Status}}' | grep -i jenkins || true"),
|
||||
k3d: ssh('kubectl get pods -A -o wide 2>/dev/null || k3d cluster list 2>/dev/null || true'),
|
||||
minio: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i minio || true"),
|
||||
mosquitto: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i mosquitto || true"),
|
||||
mysql: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'mysql|mariadb' || true"),
|
||||
napcat: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'napcat|qqbot' || true"),
|
||||
ssh: ssh('hostname && date'),
|
||||
};
|
||||
const services = (input.services?.length ?? 0) > 0 ? input.services! : Object.keys(allCommands);
|
||||
|
||||
return services
|
||||
.filter((service) => allCommands[service])
|
||||
.map((service) => ({
|
||||
command: allCommands[service],
|
||||
service,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function buildRemoteHealthCheck(input: RemoteHealthCheckInput = {}): Promise<Record<string, unknown>> {
|
||||
const commands = buildRemoteHealthCommands(input);
|
||||
const results = input.execute
|
||||
? await Promise.all(
|
||||
commands.map(async (item) => ({
|
||||
...item,
|
||||
result: await tryPowerShell(item.command, workspaceRoot),
|
||||
})),
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
commands,
|
||||
execute: input.execute ?? false,
|
||||
notes: [
|
||||
'默认 execute=false,只生成只读探测命令。',
|
||||
'远程命令只做 hostname/date/docker ps/kubectl get 等只读检查。',
|
||||
'真实密码、token、Secret 不从仓库读取,也不写入输出。',
|
||||
],
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDbSyncPlan(input: DbSyncPlanInput = {}): Record<string, unknown> {
|
||||
const source = input.source || 'local';
|
||||
const target = input.target || 'remote';
|
||||
const backupName = input.backupName || `kt_template_backup_${formatDateInShanghai().replaceAll('-', '')}`;
|
||||
|
||||
return {
|
||||
backupName,
|
||||
commands: [
|
||||
'从 Node/kt-template-online-api/.env.development 和 .env.production 读取连接信息,只在本机 shell 环境使用,不写入 Git。',
|
||||
'mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers <source-conn> kt_template > .codex-db-sync/kt_template.sql',
|
||||
`mysql <target-conn> -e "CREATE DATABASE IF NOT EXISTS ${backupName} DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"`,
|
||||
`mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers <target-conn> kt_template | mysql <target-conn> ${backupName}`,
|
||||
'mysql <target-conn> kt_template < .codex-db-sync/kt_template.sql',
|
||||
'对比源库/目标库表数量、视图/函数/过程数量和关键表行数。',
|
||||
`验证通过后再执行:mysql <target-conn> -e "DROP DATABASE ${backupName};"`,
|
||||
],
|
||||
guardrails: [
|
||||
'默认只生成计划,不执行覆盖、drop、truncate。',
|
||||
'同步前必须确认 source/target 方向,不能靠变量名猜。',
|
||||
'目标库备份校验通过前不得删除备份库。',
|
||||
'涉及生产库时优先只读验证,批量删除必须二次确认。',
|
||||
],
|
||||
source,
|
||||
target,
|
||||
verification: [
|
||||
'SHOW TABLES 数量一致。',
|
||||
'admin_user、admin_menu、qqbot_account、qqbot_message_log 等关键表行数符合预期。',
|
||||
'应用登录和核心接口 smoke 通过。',
|
||||
'全局 review findings=0。',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildComponentWorkflow(input: ComponentWorkflowInput = {}): Record<string, unknown> {
|
||||
const target = input.target || 'KtTable';
|
||||
const workflows = {
|
||||
AdminAuth: {
|
||||
antiPatterns: ['无 token 时主动 refresh', '跨端回跳时清空 Admin 已有授权态', '把 redirect 写丢或重复编码'],
|
||||
files: ['Vue/kt-template-admin/apps/web-antdv-next/src/store/auth.ts', 'Vue/kt-template-online-web/src/api/auth.ts', 'Vue/kt-template-online-playground/src/api/auth.ts'],
|
||||
guardrails: ['先画清楚 Admin/API/Web/Playground token 链路', '401 时先判断旧 token 是否存在', 'Admin 已登录时直接带回授权态'],
|
||||
verification: ['清空 token 测无循环', 'Admin 已登录测直接回跳', '刷新页面测持久化'],
|
||||
},
|
||||
BlogArgon: {
|
||||
antiPatterns: ['使用具名 id 控制 DOM', 'SCSS 类名逃出 kt-blog 命名空间', '主题变量散落在 scss 文件', '亮色模式仍套暗色遮罩'],
|
||||
files: ['Vue/kt-blog-web/src/hooks/useBlogTheme.ts', 'Vue/kt-blog-web/src/styles', 'Vue/kt-blog-web/src/components/blog'],
|
||||
guardrails: ['样式统一 kt-blog BEM 命名空间', '主题变量收敛到 useBlogTheme', '动画用 Vue Transition', '和线上 Argon DOM/视觉逐项对照'],
|
||||
verification: ['亮/暗模式切换', '搜索 Enter 和动画', '文章切换动画', '无感知滚动加载', '背景图滚动到底部不断层'],
|
||||
},
|
||||
KtTable: {
|
||||
antiPatterns: ['自定义纵向滚动条覆盖 Antdv 原生滚动', 'scroll/resize 高频写响应式状态', '表格高度参与展开收起动画', '固定列透明导致透视'],
|
||||
files: ['Vue/kt-template-admin/apps/web-antdv-next/src/components/ktTable'],
|
||||
guardrails: ['优先使用 antdv-next 原生 table 能力', '统计列固定到底部但不参与表单动画', '序号列和操作列固定最小/最大宽', 'resize 用 RAF 节流和非深度监听'],
|
||||
verification: ['横向滚动性能', '纵向滚动表头对齐', '列宽/行高 resize', 'summary 固定底部', '宽屏无无意义横向滚动条'],
|
||||
},
|
||||
QQBot: {
|
||||
antiPatterns: ['把 NapCat token 写进仓库', '删除账号不清理专属容器', '白名单/黑名单只做前端过滤', '远程服务不可达时伪装成业务成功'],
|
||||
files: ['Node/kt-template-online-api/src/qqbot', 'Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot', 'ci/fnos-k8s/deploy-qqbot-runtime.ps1'],
|
||||
guardrails: ['OneBot 反向 WS token 校验', '权限名单底层先走 QQ号过滤', '群/频道精确 QQ 过滤由开关控制', '扫码取消/过期/失败清理未绑定容器'],
|
||||
verification: ['账号在线', '扫码新增/更新登录', '自动回复规则命中', '权限名单三类过滤', '消息日志和发送日志闭环'],
|
||||
},
|
||||
} satisfies Record<string, Record<string, unknown>>;
|
||||
|
||||
return {
|
||||
target,
|
||||
workflow: workflows[target],
|
||||
};
|
||||
}
|
||||
60
src/types.ts
60
src/types.ts
@ -126,6 +126,66 @@ export interface RiskScanInput {
|
||||
project?: string;
|
||||
}
|
||||
|
||||
export interface GlobalCodeReviewInput {
|
||||
includeContentScan?: boolean;
|
||||
includeRootScan?: boolean;
|
||||
maxFindingsPerProject?: number;
|
||||
projects?: string[];
|
||||
}
|
||||
|
||||
export interface FinishTaskInput {
|
||||
changeType?: TaskType;
|
||||
cleanupHistory?: boolean;
|
||||
includeRootScan?: boolean;
|
||||
keepHistory?: number;
|
||||
maxFindingsPerProject?: number;
|
||||
projects?: string[];
|
||||
runValidation?: boolean;
|
||||
}
|
||||
|
||||
export interface CommitPlanInput {
|
||||
includePush?: boolean;
|
||||
message?: string;
|
||||
projects?: string[];
|
||||
}
|
||||
|
||||
export interface PushPlanInput {
|
||||
branch?: string;
|
||||
projects?: string[];
|
||||
remote?: string;
|
||||
}
|
||||
|
||||
export type BusinessFlow =
|
||||
| 'admin-login'
|
||||
| 'blog-crud'
|
||||
| 'qqbot-account-scan'
|
||||
| 'qqbot-auto-reply'
|
||||
| 'web-playground-auth';
|
||||
|
||||
export interface BusinessTestPlanInput {
|
||||
baseUrl?: string;
|
||||
environment?: 'local' | 'remote';
|
||||
flow?: BusinessFlow | 'all';
|
||||
}
|
||||
|
||||
export interface RemoteHealthCheckInput {
|
||||
execute?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
services?: string[];
|
||||
sshTarget?: string;
|
||||
}
|
||||
|
||||
export interface DbSyncPlanInput {
|
||||
backupName?: string;
|
||||
source?: 'local' | 'remote';
|
||||
target?: 'local' | 'remote';
|
||||
}
|
||||
|
||||
export interface ComponentWorkflowInput {
|
||||
target?: 'AdminAuth' | 'BlogArgon' | 'KtTable' | 'QQBot';
|
||||
}
|
||||
|
||||
export interface PrepareTaskInput extends GuardrailsInput {
|
||||
changedFiles?: string[];
|
||||
entryUrl?: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user