chore: 固化时间序列化工作流规则

This commit is contained in:
sunlei 2026-06-12 16:32:46 +08:00
parent efaceb7a97
commit 7e79b38600
4 changed files with 746 additions and 468 deletions

View File

@ -22,8 +22,8 @@
- 生成专项组件工作流KtTable、BlogArgon、AdminAuth、QQBot、FF14Plugin、NapCatLogin、SystemLog、Knife4jSwagger、FnosK8s 的防踩坑清单和验证点。
- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。
- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮模板目录永久保留CLI 默认 dry-run真实清理必须显式传 `--execute`
- 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、部署链路和 Vue TSX 插槽写法等高风险改动。
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、根目录生成产物、`TASKS.md` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签,避免历史误报污染上下文;任何文件改动后都要跑一遍。
- 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、根目录生成产物、`TASKS.md` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token,避免历史误报污染上下文;任何文件改动后都要跑一遍。
- 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。
- 生成提交前检查清单:校验 KT commit message 约定。
@ -70,7 +70,7 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
## 工具列表
| 工具 | 用途 |
| --- | --- |
| -------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `kt_read_context` | 读取 KT 根目录紧凑上下文和最近任务记录,支持显式读取完整文档 |
| `kt_inspect_project` | 检查子项目仓库、包管理器、Node、env 和 Git 状态 |
| `kt_inspect_all_projects` | 一次性检查所有 KT 项目,适合多仓库联动任务 |
@ -104,9 +104,9 @@ 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 复审报告,并校验 `TASKS.md` 最近记录只保留范围、关键词、验证字段、NapCat `latest` 漂移和 QQBot 重型服务单测导入风险;确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 |
| `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,默认仅对变更文件做内容深扫,输出 JSON 复审报告,并校验 `TASKS.md` 最近记录只保留范围、关键词、验证字段、NapCat `latest` 漂移和 QQBot 重型服务单测导入风险;明显测试占位 token 不报凭据风险,确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 |
| `pnpm run obsidian-context` | 输出 Obsidian 索引上下文;可传 `--module Admin`、`--query QQBot`、`--max-documents 10`。 |
| `pnpm run obsidian-validate` | 校验 KT Obsidian vault 结构和链接;默认 warning 不让脚本失败,需要严格模式时传 `--fail-on-warnings`。 |
| `pnpm run obsidian-sync` | 审计 Obsidian 工作流入口是否连通,并联动执行 validate。 |
@ -116,7 +116,7 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
## 项目别名
| 别名 | 路径 |
| --- | --- |
| ------------ | ----------------------------------- |
| `root` | `D:/MyFiles/KT` |
| `mcp` | `mcp/ktWorkflow` |
| `api` | `Node/kt-template-online-api` |
@ -168,6 +168,6 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
## 来源与许可证
| 一级来源 | 使用方式 | License |
| --- | --- | --- |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------- |
| [multica-ai/andrej-karpathy-skills](https://github.com/multica-ai/andrej-karpathy-skills) | `skills/karpathy-guidelines` 和 ktWorkflow 防偏差闭环的行为准则来源 | MIT |
| [kepano/obsidian-skills](https://github.com/kepano/obsidian-skills) | Obsidian Markdown、Canvas、Bases 和 CLI 工作流的归纳整理能力来源 | MIT |

View File

@ -1,270 +1,368 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { parseGlobalReviewCliArgs } from './core/cli.js';
import { registeredToolNames } from './core/constants.js';
import { buildBlockerResolution } from './tools/blocker.js';
import { buildChangeDocSync } from './tools/docSync.js';
import { resolveInsideRoot } from './core/workspace.js';
import { buildGuardrails } from './tools/guardrails.js';
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js';
import { buildWorkflowLoopAudit } from './tools/loop.js';
import { inspectProject } from './tools/inspect.js';
import { prepareTask, readWorkflowContext } from './tools/task.js';
import { buildGlobalCodeReview, findNapcatImageGovernanceFindings, findQqbotCommandServiceTestImportFindings, findTaskRecordGovernanceFindings, isBenignCredentialReviewValue } from './tools/review.js';
import { buildBusinessTestPlan } from './tools/testing.js';
import { buildVerificationPlan } from './tools/verification.js';
import { buildWorkstreamCloseout } from './tools/closeout.js';
import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js';
import { parseGlobalReviewCliArgs } from "./core/cli.js";
import { registeredToolNames } from "./core/constants.js";
import { buildBlockerResolution } from "./tools/blocker.js";
import { buildChangeDocSync } from "./tools/docSync.js";
import { resolveInsideRoot } from "./core/workspace.js";
import { hasEntityLoadTimeSerializationMutation } from "./tools/envRisk.js";
import { buildGuardrails } from "./tools/guardrails.js";
import {
readObsidianContext,
syncObsidianWorkflow,
validateObsidianVault,
} from "./tools/obsidian.js";
import {
cleanupHistoryArtifacts,
parseCliCleanupArgs,
} from "./tools/cleanup.js";
import { buildWorkflowLoopAudit } from "./tools/loop.js";
import { inspectProject } from "./tools/inspect.js";
import { prepareTask, readWorkflowContext } from "./tools/task.js";
import {
buildGlobalCodeReview,
findNapcatImageGovernanceFindings,
findQqbotCommandServiceTestImportFindings,
findTaskRecordGovernanceFindings,
isBenignCredentialReviewValue,
} from "./tools/review.js";
import { buildBusinessTestPlan } from "./tools/testing.js";
import { buildVerificationPlan } from "./tools/verification.js";
import { buildWorkstreamCloseout } from "./tools/closeout.js";
import {
buildCommitPlan,
buildComponentWorkflow,
buildDbSyncPlan,
buildFinishTask,
buildPushPlan,
buildRemoteHealthCheck,
} from "./tools/workflow.js";
export async function runSelfTest(): Promise<void> {
const reviewClassifier = {
localizedSecretLabelAllowed: isBenignCredentialReviewValue('密钥'),
realTokenRejected: !isBenignCredentialReviewValue('eqc2Sbp8ufP2AcS6I2YReLqxnu3XJKC6D7C6PUcx'),
localizedSecretLabelAllowed: isBenignCredentialReviewValue("密钥"),
realTokenRejected: !isBenignCredentialReviewValue(
"eqc2Sbp8ufP2AcS6I2YReLqxnu3XJKC6D7C6PUcx",
),
testTokenAllowed: isBenignCredentialReviewValue("token-test"),
};
if (!reviewClassifier.localizedSecretLabelAllowed || !reviewClassifier.realTokenRejected) {
throw new Error('review credential classifier self-check failed');
if (
!reviewClassifier.localizedSecretLabelAllowed ||
!reviewClassifier.realTokenRejected ||
!reviewClassifier.testTokenAllowed
) {
throw new Error("review credential classifier self-check failed");
}
const taskRecordFindings = findTaskRecordGovernanceFindings([
'## 最近记录',
'### 2026-06-10长记录污染',
'- 范围root',
'- 关键词TASKS 治理',
'- 页面测试用例:配置类任务,无页面测试。',
'- 验证global-review',
'## 历史索引',
"## 最近记录",
"### 2026-06-10长记录污染",
"- 范围root",
"- 关键词TASKS 治理",
"- 页面测试用例:配置类任务,无页面测试。",
"- 验证global-review",
"## 历史索引",
]);
if (!taskRecordFindings.some((item) => item.category === 'tasks-record-extra-field')) {
throw new Error('TASKS record governance self-check failed');
if (
!taskRecordFindings.some(
(item) => item.category === "tasks-record-extra-field",
)
) {
throw new Error("TASKS record governance self-check failed");
}
const napcatImageFindings = findNapcatImageGovernanceFindings(
['QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest'],
["QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest"],
{
file: '.env.example',
project: 'Node/kt-template-online-api',
file: ".env.example",
project: "Node/kt-template-online-api",
},
);
if (!napcatImageFindings.some((item) => item.category === 'napcat-latest-image')) {
throw new Error('NapCat image governance self-check failed');
if (
!napcatImageFindings.some((item) => item.category === "napcat-latest-image")
) {
throw new Error("NapCat image governance self-check failed");
}
const qqbotCommandServiceImportFindings = findQqbotCommandServiceTestImportFindings(
["import { QqbotCommandService } from '@/qqbot/command/qqbot-command.service';"],
const qqbotCommandServiceImportFindings =
findQqbotCommandServiceTestImportFindings(
[
"import { QqbotCommandService } from '@/qqbot/command/qqbot-command.service';",
],
{
file: 'test/qqbot/command/qqbot-command-cooldown.service.spec.ts',
project: 'Node/kt-template-online-api',
file: "test/qqbot/command/qqbot-command-cooldown.service.spec.ts",
project: "Node/kt-template-online-api",
},
);
if (
!qqbotCommandServiceImportFindings.some(
(item) => item.category === 'qqbot-command-service-heavy-test-import',
(item) => item.category === "qqbot-command-service-heavy-test-import",
)
) {
throw new Error('QQBot command service test import governance self-check failed');
throw new Error(
"QQBot command service test import governance self-check failed",
);
}
const reviewCliParser = {
dashedAll: parseGlobalReviewCliArgs(['node', 'server', '--global-review', '--content-scan-all']).contentScanMode,
keyValueAll: parseGlobalReviewCliArgs(['node', 'server', '--global-review', '--contentScanMode=all']).contentScanMode,
splitAll: parseGlobalReviewCliArgs(['node', 'server', '--global-review', '--content-scan-mode', 'all']).contentScanMode,
dashedAll: parseGlobalReviewCliArgs([
"node",
"server",
"--global-review",
"--content-scan-all",
]).contentScanMode,
keyValueAll: parseGlobalReviewCliArgs([
"node",
"server",
"--global-review",
"--contentScanMode=all",
]).contentScanMode,
splitAll: parseGlobalReviewCliArgs([
"node",
"server",
"--global-review",
"--content-scan-mode",
"all",
]).contentScanMode,
};
if (
reviewCliParser.dashedAll !== 'all' ||
reviewCliParser.keyValueAll !== 'all' ||
reviewCliParser.splitAll !== 'all'
reviewCliParser.dashedAll !== "all" ||
reviewCliParser.keyValueAll !== "all" ||
reviewCliParser.splitAll !== "all"
) {
throw new Error('global-review CLI content scan mode parser self-check failed');
throw new Error(
"global-review CLI content scan mode parser self-check failed",
);
}
const refactorGuardrails = buildGuardrails({
project: 'mcp',
taskType: 'refactor',
userRequest: '重构 KT 工作流护栏',
project: "mcp",
taskType: "refactor",
userRequest: "重构 KT 工作流护栏",
});
const refactorGuardrailOk =
refactorGuardrails.guardrails.beforeEdit.some((item) => item.includes('重构前写清楚')) &&
refactorGuardrails.guardrails.verification.notes.some((item) => item.includes('重构验证必须覆盖原行为'));
refactorGuardrails.guardrails.beforeEdit.some((item) =>
item.includes("重构前写清楚"),
) &&
refactorGuardrails.guardrails.verification.notes.some((item) =>
item.includes("重构验证必须覆盖原行为"),
);
if (!refactorGuardrailOk) {
throw new Error('refactor guardrail self-check failed');
throw new Error("refactor guardrail self-check failed");
}
const apiVerification = buildVerificationPlan({
changeType: 'api',
project: 'api',
changeType: "api",
project: "api",
});
const apiVerificationCommands = apiVerification.commands.join('\n');
const apiVerificationCommands = apiVerification.commands.join("\n");
if (
!apiVerificationCommands.includes('pnpm exec jest --runInBand') ||
apiVerificationCommands.includes('passWithNoTests')
!apiVerificationCommands.includes("pnpm exec jest --runInBand") ||
apiVerificationCommands.includes("passWithNoTests")
) {
throw new Error('API Jest verification command self-check failed');
throw new Error("API Jest verification command self-check failed");
}
const timeSerializationRisk = {
bad: hasEntityLoadTimeSerializationMutation(
"import { AfterLoad } from 'typeorm';\nexport const FormatDateTime = () => {}; formatDateTimeFields(this);",
),
good: hasEntityLoadTimeSerializationMutation(
"import { Column } from 'typeorm';\nexport class KtDateTime extends Date {}\nexport const KtDateTimeColumn = () => Column({ transformer: { from: (value) => new KtDateTime(value), to: (value) => value } });",
),
};
if (!timeSerializationRisk.bad || timeSerializationRisk.good) {
throw new Error("time serialization mutation risk self-check failed");
}
const databaseVerification = buildVerificationPlan({
changeType: 'database',
project: 'api',
changeType: "database",
project: "api",
});
const databaseVerificationNotes = databaseVerification.notes.join('\n');
const databaseVerificationNotes = databaseVerification.notes.join("\n");
if (
!databaseVerificationNotes.includes('备份目标表') ||
!databaseVerificationNotes.includes('SHOW COLUMNS') ||
!databaseVerificationNotes.includes('真实接口 smoke')
!databaseVerificationNotes.includes("备份目标表") ||
!databaseVerificationNotes.includes("SHOW COLUMNS") ||
!databaseVerificationNotes.includes("真实接口 smoke")
) {
throw new Error('database migration verification self-check failed');
throw new Error("database migration verification self-check failed");
}
const closeoutNeedsUpgrade = buildWorkstreamCloseout({
cleanupEvidence: ['cleanup-history dry-run deleted=0。'],
docSyncEvidence: ['kt_change_doc_sync requiredDocs 已处理。'],
reviewEvidence: ['global-review findings=0。'],
problemRecords: ['Jenkins 状态需要结合日志和 K8s 状态确认。'],
reusablePatterns: ['deploy-observation'],
stableSolutions: ['收尾时固定观测 build、commit、镜像、Deployment、Pod 和 smoke。'],
title: '发布闭环',
verificationEvidence: ['Jenkins #132 SUCCESSK8s Pod Running。'],
cleanupEvidence: ["cleanup-history dry-run deleted=0。"],
docSyncEvidence: ["kt_change_doc_sync requiredDocs 已处理。"],
reviewEvidence: ["global-review findings=0。"],
problemRecords: ["Jenkins 状态需要结合日志和 K8s 状态确认。"],
reusablePatterns: ["deploy-observation"],
stableSolutions: [
"收尾时固定观测 build、commit、镜像、Deployment、Pod 和 smoke。",
],
title: "发布闭环",
verificationEvidence: ["Jenkins #132 SUCCESSK8s Pod Running。"],
});
if ((closeoutNeedsUpgrade.canReportComplete as boolean) !== false) {
throw new Error('workstream closeout upgrade gate self-check failed');
throw new Error("workstream closeout upgrade gate self-check failed");
}
const closeoutComplete = buildWorkstreamCloseout({
cleanupEvidence: ['cleanup-history dry-run deleted=0。'],
docSyncEvidence: ['无需文档更新,已记录原因。'],
cleanupEvidence: ["cleanup-history dry-run deleted=0。"],
docSyncEvidence: ["无需文档更新,已记录原因。"],
ktWorkflowUpdated: true,
problemRecords: ['无新卡点。'],
reviewEvidence: ['global-review findings=0。'],
reusablePatterns: ['none'],
stableSolutions: ['无新增稳定解法。'],
title: '文档收尾',
verificationEvidence: ['typecheck/self-test/global-review 通过。'],
problemRecords: ["无新卡点。"],
reviewEvidence: ["global-review findings=0。"],
reusablePatterns: ["none"],
stableSolutions: ["无新增稳定解法。"],
title: "文档收尾",
verificationEvidence: ["typecheck/self-test/global-review 通过。"],
});
if ((closeoutComplete.canReportComplete as boolean) !== true) {
throw new Error('workstream closeout complete self-check failed');
throw new Error("workstream closeout complete self-check failed");
}
const closeoutInvalidCleanup = buildWorkstreamCloseout({
cleanupEvidence: ['cleanup-history dry-run deleted=3。'],
docSyncEvidence: ['kt_change_doc_sync requiredDocs 已处理。'],
problemRecords: ['无新卡点。'],
reviewEvidence: ['global-review findings=0。'],
reusablePatterns: ['none'],
stableSolutions: ['无新增稳定解法。'],
title: '无效清理证据',
verificationEvidence: ['typecheck 通过。'],
cleanupEvidence: ["cleanup-history dry-run deleted=3。"],
docSyncEvidence: ["kt_change_doc_sync requiredDocs 已处理。"],
problemRecords: ["无新卡点。"],
reviewEvidence: ["global-review findings=0。"],
reusablePatterns: ["none"],
stableSolutions: ["无新增稳定解法。"],
title: "无效清理证据",
verificationEvidence: ["typecheck 通过。"],
});
if ((closeoutInvalidCleanup.canReportComplete as boolean) !== false) {
throw new Error('workstream closeout cleanup evidence self-check failed');
throw new Error("workstream closeout cleanup evidence self-check failed");
}
const obsidianContext = readObsidianContext({
maxDocuments: 6,
module: 'ktWorkflow',
module: "ktWorkflow",
});
if ((obsidianContext.summary as { returnedDocuments?: number }).returnedDocuments === 0) {
throw new Error('obsidian context self-check failed');
if (
(obsidianContext.summary as { returnedDocuments?: number })
.returnedDocuments === 0
) {
throw new Error("obsidian context self-check failed");
}
if ((obsidianContext.summary as { codeAnchors?: number }).codeAnchors === 0) {
throw new Error('obsidian code anchor self-check failed');
throw new Error("obsidian code anchor self-check failed");
}
const obsidianValidation = validateObsidianVault({
includeStaleReferences: false,
});
if ((obsidianValidation.summary as { errors?: number }).errors !== 0) {
throw new Error('obsidian validation self-check failed');
throw new Error("obsidian validation self-check failed");
}
const cleanupCliParser = {
defaultDryRun: parseCliCleanupArgs(['node', 'server', '--cleanup-history']).dryRun,
executeDryRun: parseCliCleanupArgs(['node', 'server', '--cleanup-history', '--execute']).dryRun,
defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"])
.dryRun,
executeDryRun: parseCliCleanupArgs([
"node",
"server",
"--cleanup-history",
"--execute",
]).dryRun,
};
if (!cleanupCliParser.defaultDryRun || cleanupCliParser.executeDryRun) {
throw new Error('cleanup CLI dry-run parser self-check failed');
throw new Error("cleanup CLI dry-run parser self-check failed");
}
const cleanupPreview = cleanupHistoryArtifacts({
dryRun: true,
keep: 3,
roots: ['.kt-workspace/test-artifacts', '.kt-workspace'],
roots: [".kt-workspace/test-artifacts", ".kt-workspace"],
});
const cleanupRoots = cleanupPreview.roots as Array<{
preservedNames: string[];
root: string;
}>;
const testArtifactsRoot = cleanupRoots.find((item) => item.root === '.kt-workspace/test-artifacts');
const workspaceHistoryRoot = cleanupRoots.find((item) => item.root === '.kt-workspace');
if (!testArtifactsRoot?.preservedNames.includes('_templates')) {
throw new Error('cleanup template preservation self-check failed');
const testArtifactsRoot = cleanupRoots.find(
(item) => item.root === ".kt-workspace/test-artifacts",
);
const workspaceHistoryRoot = cleanupRoots.find(
(item) => item.root === ".kt-workspace",
);
if (!testArtifactsRoot?.preservedNames.includes("_templates")) {
throw new Error("cleanup template preservation self-check failed");
}
if (!workspaceHistoryRoot?.preservedNames.includes('test-artifacts')) {
throw new Error('cleanup workspace root preservation self-check failed');
if (!workspaceHistoryRoot?.preservedNames.includes("test-artifacts")) {
throw new Error("cleanup workspace root preservation self-check failed");
}
const docSyncBangDream = await buildChangeDocSync({
changedFiles: ['src/qqbot/plugins/bangDream/application/bangdream-application.service.ts'],
project: 'api',
changedFiles: [
"src/qqbot/plugins/bangDream/application/bangdream-application.service.ts",
],
project: "api",
});
const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{ path: string }>;
if (!docSyncRequiredDocs.some((item) => item.path === 'Node/kt-template-online-api/API.md')) {
throw new Error('doc sync API self-check failed');
const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{
path: string;
}>;
if (
!docSyncRequiredDocs.some(
(item) => item.path === "Node/kt-template-online-api/API.md",
)
) {
throw new Error("doc sync API self-check failed");
}
const loopAuditComplete = buildWorkflowLoopAudit({
cleanupExecuted: true,
cleanupFinalDeleted: 0,
cleanupPreviewDeleted: 3,
docSyncEvidence: ['kt_change_doc_sync requiredDocs 已处理。'],
docSyncEvidence: ["kt_change_doc_sync requiredDocs 已处理。"],
ktWorkflowUpdated: true,
problemRecords: ['测试历史清理流程未自动执行。'],
problemRecords: ["测试历史清理流程未自动执行。"],
reusablePattern: true,
reviewEvidence: ['global-review findings=0'],
stableSolutions: ['cleanup-history 默认 dry-run显式 --execute 后复验 deleted=0。'],
taskTitle: '自动化闭环审计',
testCases: ['cleanup-history dry-run/execute 回归测试'],
verificationEvidence: ['typecheck/self-test/cleanup dry-run 通过'],
reviewEvidence: ["global-review findings=0"],
stableSolutions: [
"cleanup-history 默认 dry-run显式 --execute 后复验 deleted=0。",
],
taskTitle: "自动化闭环审计",
testCases: ["cleanup-history dry-run/execute 回归测试"],
verificationEvidence: ["typecheck/self-test/cleanup dry-run 通过"],
});
if ((loopAuditComplete.canReportComplete as boolean) !== true) {
throw new Error('workflow loop complete self-check failed');
throw new Error("workflow loop complete self-check failed");
}
const loopAuditBlocked = buildWorkflowLoopAudit({
cleanupPreviewDeleted: 1,
taskTitle: '缺证据闭环',
taskTitle: "缺证据闭环",
});
if ((loopAuditBlocked.canReportComplete as boolean) !== false) {
throw new Error('workflow loop blocked self-check failed');
throw new Error("workflow loop blocked self-check failed");
}
const loopAuditMissingCleanup = buildWorkflowLoopAudit({
docSyncEvidence: ['kt_change_doc_sync requiredDocs 已处理。'],
reviewEvidence: ['global-review findings=0。'],
taskTitle: '缺清理证据闭环',
testCases: ['self-test'],
verificationEvidence: ['typecheck 通过。'],
docSyncEvidence: ["kt_change_doc_sync requiredDocs 已处理。"],
reviewEvidence: ["global-review findings=0。"],
taskTitle: "缺清理证据闭环",
testCases: ["self-test"],
verificationEvidence: ["typecheck 通过。"],
});
if ((loopAuditMissingCleanup.canReportComplete as boolean) !== false) {
throw new Error('workflow loop missing cleanup evidence self-check failed');
throw new Error("workflow loop missing cleanup evidence self-check failed");
}
const dbSyncPlan = buildDbSyncPlan({});
const dbSyncCommands = (dbSyncPlan.commands as string[]).join('\n');
const dbSyncCommands = (dbSyncPlan.commands as string[]).join("\n");
const backupName = dbSyncPlan.backupName as string;
if (
!backupName.includes('_') ||
!dbSyncCommands.includes('.kt-workspace/db-sync') ||
dbSyncCommands.includes('CREATE DATABASE IF NOT EXISTS')
!backupName.includes("_") ||
!dbSyncCommands.includes(".kt-workspace/db-sync") ||
dbSyncCommands.includes("CREATE DATABASE IF NOT EXISTS")
) {
throw new Error('DB sync plan safety self-check failed');
throw new Error("DB sync plan safety self-check failed");
}
const data = {
context: readWorkflowContext({ taskRecordCount: 2 }),
guardrails: buildGuardrails({
project: 'mcp',
taskType: 'mcp',
userRequest: '扩展 KT 工作区 MCP 可复用能力',
project: "mcp",
taskType: "mcp",
userRequest: "扩展 KT 工作区 MCP 可复用能力",
}),
cleanupCliParser,
cleanupPreview,
@ -275,26 +373,29 @@ export async function runSelfTest(): Promise<void> {
dryRun: true,
keep: 3,
}),
inspectAdmin: await inspectProject({ includeGit: false, project: 'admin' }),
inspectKnife4j: await inspectProject({ includeGit: false, project: 'knife4j' }),
inspectAdmin: await inspectProject({ includeGit: false, project: "admin" }),
inspectKnife4j: await inspectProject({
includeGit: false,
project: "knife4j",
}),
prepareTask: await prepareTask({
includeGit: false,
project: 'mcp',
taskType: 'mcp',
userRequest: '扩展 KT 工作区 MCP 可复用能力',
project: "mcp",
taskType: "mcp",
userRequest: "扩展 KT 工作区 MCP 可复用能力",
}),
review: await buildGlobalCodeReview({
includeContentScan: true,
includeRootScan: true,
maxFindingsPerProject: 10,
projects: ['mcp'],
projects: ["mcp"],
}),
reviewCliParser,
reviewClassifier,
refactorGuardrails,
apiVerification,
businessTestPlan: buildBusinessTestPlan({
flow: 'system-log-visualization',
flow: "system-log-visualization",
}),
closeoutComplete,
closeoutNeedsUpgrade,
@ -305,41 +406,47 @@ export async function runSelfTest(): Promise<void> {
obsidianValidation,
blockerResolution: buildBlockerResolution({
attempts: 2,
evidence: 'self-test',
problem: '重复超时',
project: 'mcp',
solution: '改用固定脚本和 timeout 后再验证',
evidence: "self-test",
problem: "重复超时",
project: "mcp",
solution: "改用固定脚本和 timeout 后再验证",
}),
commitPlan: await buildCommitPlan({
projects: ['mcp'],
projects: ["mcp"],
}),
componentWorkflow: buildComponentWorkflow({
target: 'SystemLog',
target: "SystemLog",
}),
dbSyncPlan,
finishTask: await buildFinishTask({
cleanupHistory: false,
projects: ['mcp'],
projects: ["mcp"],
runValidation: false,
}),
pushPlan: await buildPushPlan({
projects: ['mcp'],
projects: ["mcp"],
}),
remoteHealthCheck: await buildRemoteHealthCheck({
execute: false,
services: ['ssh', 'docker'],
services: ["ssh", "docker"],
}),
verification: buildVerificationPlan({
changeType: 'mcp',
project: 'mcp',
changeType: "mcp",
project: "mcp",
}),
};
const outputDir = resolveInsideRoot('.kt-workspace/verify/ktWorkflow');
const outputDir = resolveInsideRoot(".kt-workspace/verify/ktWorkflow");
mkdirSync(outputDir, { recursive: true });
writeFileSync(
path.join(outputDir, 'self-test.json'),
path.join(outputDir, "self-test.json"),
JSON.stringify(data, null, 2),
'utf8',
"utf8",
);
console.log(
JSON.stringify(
{ ok: true, outputDir, tools: registeredToolNames.length },
null,
2,
),
);
console.log(JSON.stringify({ ok: true, outputDir, tools: registeredToolNames.length }, null, 2));
}

View File

@ -1,17 +1,22 @@
import { existsSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { existsSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import type { ProjectAlias } from '../types.js';
import type { EnvPolicyInput, RiskScanInput } from '../types.js';
import { tryExecFile } from '../core/exec.js';
import { detectRepoType, parseGitStatusFiles } from '../core/project.js';
import { readTextIfExists, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
import type { ProjectAlias } from "../types.js";
import type { EnvPolicyInput, RiskScanInput } from "../types.js";
import { tryExecFile } from "../core/exec.js";
import { detectRepoType, parseGitStatusFiles } from "../core/project.js";
import {
readTextIfExists,
resolveProject,
toPosix,
workspaceRoot,
} from "../core/workspace.js";
const frontendEnvProjectAliases = new Set<ProjectAlias>([
'admin',
'blog',
'playground',
'web',
"admin",
"blog",
"playground",
"web",
]);
function allowsTrackedFrontendEnv(projectAlias: ProjectAlias | null): boolean {
@ -19,29 +24,60 @@ function allowsTrackedFrontendEnv(projectAlias: ProjectAlias | null): boolean {
}
function readSmallFileIfExists(filePath: string): string {
if (!existsSync(filePath)) return '';
if (!existsSync(filePath)) return "";
try {
const stats = statSync(filePath);
if (!stats.isFile() || stats.size > 512 * 1024) return '';
return readFileSync(filePath, 'utf8');
if (!stats.isFile() || stats.size > 512 * 1024) return "";
return readFileSync(filePath, "utf8");
} catch {
return '';
return "";
}
}
export function hasEntityLoadTimeSerializationMutation(
source: string,
): boolean {
if (!/\bAfterLoad\b/.test(source)) return false;
if (
!/\bKtDateTimeField\b|\btransformKtDateTimeFields\b|\bFormatDateTime\b|\bformatDateTimeFields\b/.test(
source,
)
) {
return false;
}
const usesDateSubclass = /\bclass\s+KtDateTime\s+extends\s+Date\b/.test(
source,
);
const returnsPlainFormattedValue = /return\s+formatKtDateTime\(\s*value/.test(
source,
);
const assignsPlainFormattedValue = /\[[^\]]+\]\s*=\s*formatKtDateTime\(/.test(
source,
);
return (
!usesDateSubclass ||
returnsPlainFormattedValue ||
assignsPlainFormattedValue
);
}
export function buildEnvPolicy(input: EnvPolicyInput): Record<string, unknown> {
const project = resolveProject(input.project);
const frontendEnvAllowed = allowsTrackedFrontendEnv(project.alias);
const envFiles = ['.env.development', '.env.production', '.env.example'].map((file) => {
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 gitignore = readTextIfExists(path.join(project.path, ".gitignore"));
const ignoredRules = gitignore
.split(/\r?\n/)
.map((line) => line.trim())
@ -49,31 +85,40 @@ export function buildEnvPolicy(input: EnvPolicyInput): Record<string, unknown> {
return {
envFiles,
ignoredRules: ignoredRules.filter((line) => line.includes('.env')),
ignoredRules: ignoredRules.filter((line) => line.includes(".env")),
policy: frontendEnvAllowed
? [
'前端项目的 .env.development/.env.production 可以跟随仓库,用于客户端构建变量。',
'前端 env 仍不得写入服务端密钥、数据库密码、WordPress 管理员密码、MinIO Secret 或 token。',
'.env.example 继续维护变量名和示例值。',
"前端项目的 .env.development/.env.production 可以跟随仓库,用于客户端构建变量。",
"前端 env 仍不得写入服务端密钥、数据库密码、WordPress 管理员密码、MinIO Secret 或 token。",
".env.example 继续维护变量名和示例值。",
]
: [
'真实开发和生产配置只保留在本地,不提交。',
'.env.example 用于维护变量名和示例值。',
'接口、数据库、WordPress、MinIO 等真实账号密码不得写入文档或提交记录。',
"真实开发和生产配置只保留在本地,不提交。",
".env.example 用于维护变量名和示例值。",
"接口、数据库、WordPress、MinIO 等真实账号密码不得写入文档或提交记录。",
],
project,
warnings: envFiles
.filter((file) => file.file !== '.env.example' && file.exists && !frontendEnvAllowed)
.filter(
(file) =>
file.file !== ".env.example" && file.exists && !frontendEnvAllowed,
)
.map((file) => `${file.file} 存在,提交前确认已被 .gitignore 忽略。`),
};
}
export async function scanTaskRisk(input: RiskScanInput): Promise<Record<string, unknown>> {
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 (changedFiles.length === 0 && detectRepoType(project.path) === "git") {
const status = await tryExecFile(
"git",
["status", "--short"],
project.path,
);
if (status.ok) {
changedFiles = parseGitStatusFiles(status.stdout);
}
@ -81,12 +126,12 @@ export async function scanTaskRisk(input: RiskScanInput): Promise<Record<string,
const risks: Array<{
file: string;
level: 'high' | 'medium';
level: "high" | "medium";
reason: string;
suggestion: string;
}> = [];
const addRisk = (
level: 'high' | 'medium',
level: "high" | "medium",
file: string,
reason: string,
suggestion: string,
@ -95,119 +140,175 @@ export async function scanTaskRisk(input: RiskScanInput): Promise<Record<string,
};
for (const file of changedFiles) {
const normalized = file.replaceAll('\\', '/');
const normalized = file.replaceAll("\\", "/");
if (/(^|\/)\.env($|\..+)/.test(normalized)) {
if (allowsTrackedFrontendEnv(project.alias)) {
addRisk(
'medium',
"medium",
file,
'前端构建环境文件有改动。',
'确认只包含客户端公开配置,不包含服务端密钥、数据库密码或 token。',
"前端构建环境文件有改动。",
"确认只包含客户端公开配置,不包含服务端密钥、数据库密码或 token。",
);
} else {
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',
"high",
file,
'KtTable 是高频核心组件,布局和滚动性能容易回归。',
'保持原生 antdv-next 表格能力,避免监听 scroll/resize 做高频状态更新。',
"真实环境配置文件有改动。",
"确认不会提交真实密钥和密码。",
);
}
}
if (
/package\.json$|pnpm-lock\.yaml$|package-lock\.json$|yarn\.lock$/.test(
normalized,
)
) {
addRisk(
"medium",
file,
"依赖或脚本发生变化。",
"确认锁文件与 package.json 同步,并运行对应轻量验证。",
);
}
if (/src\/admin\/|src\/.*controller|src\/.*service|src\/.*dto/.test(normalized)) {
addRisk('medium', file, '后端接口或业务逻辑改动。', '本地启动或复用服务后真实调用对应接口。');
}
if (/src\/common\/decorators\/format-date-time\.decorator\.ts|src\/.*\.(entity|dto)\.ts/.test(normalized)) {
if (normalized.includes("/components/ktTable/")) {
addRisk(
'medium',
"medium",
file,
'后端时间字段或 DTO/Entity 响应结构改动。',
'确认数据源经 DTO/Entity 实例返回FormatDateTime 不做递归响应遍历,时间统一 YYYY-MM-DD HH:mm:ss。',
"KtTable 是高频核心组件,布局和滚动性能容易回归。",
"保持原生 antdv-next 表格能力,避免监听 scroll/resize 做高频状态更新。",
);
}
if (/src\/common\/logger\/|src\/admin\/system-log\/|pino|loki|system-log/i.test(normalized)) {
if (
/src\/admin\/|src\/.*controller|src\/.*service|src\/.*dto/.test(
normalized,
)
) {
addRisk(
'medium',
"medium",
file,
'Pino/Loki 或系统日志链路改动。',
'验证 API 日志写入、/system/logs 系列接口、Admin 系统日志页面和 Loki ready/query_range。',
"后端接口或业务逻辑改动。",
"本地启动或复用服务后真实调用对应接口。",
);
}
if (
/src\/common\/decorators\/kt-date-time\.decorator\.ts|src\/.*\.(entity|dto)\.ts/.test(
normalized,
)
) {
addRisk(
"medium",
file,
"后端时间字段或 DTO/Entity 响应结构改动。",
"确认 Entity 使用 KtDateTimeColumn/KtCreateDateColumn/KtUpdateDateColumnDTO 使用 KtDateTimeField + transformKtDateTimeFields不要在响应包装里递归遍历也不要把业务态 Date 改成普通字符串。",
);
}
if (
/src\/common\/decorators\/kt-date-time\.decorator\.ts$/.test(normalized)
) {
const source = readSmallFileIfExists(path.join(project.path, normalized));
if (hasEntityLoadTimeSerializationMutation(source)) {
addRisk(
"high",
file,
"KtDateTime 装饰器疑似在 Entity 加载阶段把时间改成字符串。",
"Entity hydrate 只能通过 TypeORM transformer 包装成 KtDateTime(Date 子类)DTO 只能在字段 transformer 边界转换;不要在 vbenSuccess/ToolsService.res 做全量递归序列化。",
);
}
}
if (
/src\/common\/logger\/|src\/admin\/system-log\/|pino|loki|system-log/i.test(
normalized,
)
) {
addRisk(
"medium",
file,
"Pino/Loki 或系统日志链路改动。",
"验证 API 日志写入、/system/logs 系列接口、Admin 系统日志页面和 Loki ready/query_range。",
);
}
if (/src\/qqbot\/plugins\/(ff14Market|fflogs)\//i.test(normalized)) {
addRisk(
'medium',
"medium",
file,
'FF14/FFLogs 命令插件改动。',
'确认中文查询和回显走字典映射,命令注册表同步,真实命令输出字段完整。',
"FF14/FFLogs 命令插件改动。",
"确认中文查询和回显走字典映射,命令注册表同步,真实命令输出字段完整。",
);
}
if (/src\/qqbot\/(account|napcat|connection)\//i.test(normalized)) {
addRisk(
'medium',
"medium",
file,
'QQBot/NapCat 登录链路改动。',
'验证新增账号和更新登录都能生成有效二维码SSE 进度可见,旧 isLogin 状态不会短路。',
"QQBot/NapCat 登录链路改动。",
"验证新增账号和更新登录都能生成有效二维码SSE 进度可见,旧 isLogin 状态不会短路。",
);
}
if (/apps\/web-antdv-next\/src\/views\/system\/(dict|log)\//.test(normalized)) {
if (
/apps\/web-antdv-next\/src\/views\/system\/(dict|log)\//.test(normalized)
) {
addRisk(
'medium',
"medium",
file,
'Admin 系统管理页改动。',
'验证菜单权限、KtTable 展示、列表筛选和对应 API 响应结构。',
"Admin 系统管理页改动。",
"验证菜单权限、KtTable 展示、列表筛选和对应 API 响应结构。",
);
}
if (/Jenkinsfile|deploy\/|k8s\/|nginx.*\.conf/.test(normalized)) {
addRisk('medium', file, '部署链路配置改动。', '只验证配置语法或生成产物,不主动触发远程部署。');
addRisk(
"medium",
file,
"部署链路配置改动。",
"只验证配置语法或生成产物,不主动触发远程部署。",
);
}
if (/\.(tsx|jsx|vue)$/.test(normalized)) {
const source = readSmallFileIfExists(path.join(project.path, normalized));
if (/\bfunction\s+render[A-Z0-9_]/.test(source)) {
addRisk(
'medium',
"medium",
file,
'Vue TSX render helper 使用 function 声明,容易在插槽渲染时踩 this/上下文问题。',
'改为箭头函数,并在嵌套组件插槽中显式传 default。',
"Vue TSX render helper 使用 function 声明,容易在插槽渲染时踩 this/上下文问题。",
"改为箭头函数,并在嵌套组件插槽中显式传 default。",
);
}
if (/<(?:A?TabPane|Tabs\.TabPane)\b[\s\S]*?>\s*\{\s*(?!\{[\s\S]*?\bdefault\s*:)/.test(source)) {
if (
/<(?:A?TabPane|Tabs\.TabPane)\b[\s\S]*?>\s*\{\s*(?!\{[\s\S]*?\bdefault\s*:)/.test(
source,
)
) {
addRisk(
'medium',
"medium",
file,
'TabPane 插槽疑似未显式声明 default。',
'Vue TSX 嵌套组件插槽使用 {{ default: () => ... }} 或等价显式 default 写法。',
"TabPane 插槽疑似未显式声明 default。",
"Vue TSX 嵌套组件插槽使用 {{ default: () => ... }} 或等价显式 default 写法。",
);
}
if (/\bonUpdate:[A-Za-z_$][\w$]*/.test(source)) {
addRisk(
'medium',
"medium",
file,
'Vue TSX 中存在手写 onUpdate:* 绑定。',
'优先使用原生 v-model:value / v-model:activeKey 等写法,减少事件名偏差。',
"Vue TSX 中存在手写 onUpdate:* 绑定。",
"优先使用原生 v-model:value / v-model:activeKey 等写法,减少事件名偏差。",
);
}
if (/\bdestroyOnClose\b/.test(source)) {
addRisk(
'medium',
"medium",
file,
'Antdv Modal destroyOnClose 已废弃。',
'改用 destroyOnHidden或优先复用 Vben Modal/Form 封装。',
"Antdv Modal destroyOnClose 已废弃。",
"改用 destroyOnHidden或优先复用 Vben Modal/Form 封装。",
);
}
}
@ -218,9 +319,9 @@ export async function scanTaskRisk(input: RiskScanInput): Promise<Record<string,
project,
risks,
safeDefaults: [
'不覆盖用户已有改动。',
'只运行必要验证。',
'提交前重新检查 status 和 diff。',
"不覆盖用户已有改动。",
"只运行必要验证。",
"提交前重新检查 status 和 diff。",
],
};
}

View File

@ -1,43 +1,48 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
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, parseGitStatusFiles } from '../core/project.js';
import { resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
import { scanTaskRisk } from './envRisk.js';
import type {
GlobalCodeReviewInput,
ProjectAlias,
RepoType,
ResolvedProject,
} from "../types.js";
import { tryExecFile } from "../core/exec.js";
import { detectRepoType, parseGitStatusFiles } 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',
'knife4j',
'fnosK8s',
"api",
"admin",
"blog",
"web",
"playground",
"mcp",
"knife4j",
"fnosK8s",
] as const;
const reviewSkipDirectoryNames = new Set([
'.git',
'.turbo',
'.vite',
'coverage',
'dist',
'node_modules',
'playwright-report',
'test-results',
".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 rootGeneratedArtifactNames = new Set([
'.codex-db-sync',
'.codex-test-logs',
'.codex-verify',
'.playwright-mcp',
'codex-test-logs',
'test-artifacts',
".codex-db-sync",
".codex-test-logs",
".codex-verify",
".playwright-mcp",
"codex-test-logs",
"test-artifacts",
]);
const sensitiveTrackedFilePattern =
@ -46,24 +51,24 @@ const sensitiveTrackedFilePattern =
const allowedSensitiveTrackedFilePattern = /(^|\/)\.env\.example$/i;
const frontendEnvProjectAliases = new Set<ProjectAlias>([
'admin',
'blog',
'playground',
'web',
"admin",
"blog",
"playground",
"web",
]);
const credentialAssignmentPattern =
/\b(password|passwd|secret|token|webuiToken|accessToken|privateKey)\b\s*[:=]\s*(["'])([^"']+)\2/i;
const napcatImageAssignmentPattern =
/^\s*QQBOT_NAPCAT_IMAGE\s*=\s*([^#\s]+)\s*(?:#.*)?$/;
const taskRecordAllowedFields = new Set(['范围', '关键词', '验证']);
const taskRecordRequiredFields = ['范围', '关键词', '验证'] as const;
const taskRecordAllowedFields = new Set(["范围", "关键词", "验证"]);
const taskRecordRequiredFields = ["范围", "关键词", "验证"] as const;
interface ReviewFinding {
category: string;
file?: string;
line?: number;
level: 'P1' | 'P2' | 'P3';
level: "P1" | "P2" | "P3";
message: string;
project: string;
suggestion: string;
@ -82,22 +87,23 @@ interface ReviewProjectResult {
trackedSensitiveFiles: string[];
}
export function findTaskRecordGovernanceFindings(lines: string[]): ReviewFinding[] {
export function findTaskRecordGovernanceFindings(
lines: string[],
): ReviewFinding[] {
const findings: ReviewFinding[] = [];
const recentStart = lines.findIndex((line) => line.trim() === '## 最近记录');
const recentStart = lines.findIndex((line) => line.trim() === "## 最近记录");
if (recentStart < 0) return findings;
const nextSectionOffset = lines
.slice(recentStart + 1)
.findIndex((line) => /^##\s+/.test(line.trim()));
const recentEnd = nextSectionOffset < 0 ? lines.length : recentStart + 1 + nextSectionOffset;
let currentRecord:
| {
const recentEnd =
nextSectionOffset < 0 ? lines.length : recentStart + 1 + nextSectionOffset;
let currentRecord: {
fields: Set<string>;
heading: string;
line: number;
}
| null = null;
} | null = null;
const pushMissingFieldFindings = () => {
if (!currentRecord) return;
@ -106,24 +112,25 @@ export function findTaskRecordGovernanceFindings(lines: string[]): ReviewFinding
if (currentRecord.fields.has(field)) continue;
findings.push({
category: 'tasks-record-missing-field',
file: 'TASKS.md',
category: "tasks-record-missing-field",
file: "TASKS.md",
line: currentRecord.line,
level: 'P2',
level: "P2",
message: `TASKS 最近记录缺少「${field}」字段。`,
project: 'root',
suggestion: '最近记录只保留范围、关键词、验证三项,详细过程迁入 docs 或历史索引。',
project: "root",
suggestion:
"最近记录只保留范围、关键词、验证三项,详细过程迁入 docs 或历史索引。",
});
}
};
for (let index = recentStart + 1; index < recentEnd; index += 1) {
const line = lines[index].trim();
if (line.startsWith('### ')) {
if (line.startsWith("### ")) {
pushMissingFieldFindings();
currentRecord = {
fields: new Set<string>(),
heading: line.replace(/^###\s+/, ''),
heading: line.replace(/^###\s+/, ""),
line: index + 1,
};
continue;
@ -141,13 +148,14 @@ export function findTaskRecordGovernanceFindings(lines: string[]): ReviewFinding
}
findings.push({
category: 'tasks-record-extra-field',
file: 'TASKS.md',
category: "tasks-record-extra-field",
file: "TASKS.md",
line: index + 1,
level: 'P2',
level: "P2",
message: `TASKS 最近记录「${currentRecord.heading}」包含额外字段「${field}」。`,
project: 'root',
suggestion: '把额外说明迁入 docsTASKS 最近记录只保留范围、关键词、验证。',
project: "root",
suggestion:
"把额外说明迁入 docsTASKS 最近记录只保留范围、关键词、验证。",
});
}
@ -164,23 +172,24 @@ export function findNapcatImageGovernanceFindings(
): ReviewFinding[] {
return lines.flatMap((line, index) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return [];
if (!trimmed || trimmed.startsWith("#")) return [];
const matched = line.match(napcatImageAssignmentPattern);
if (!matched) return [];
const image = matched[1].trim();
if (!image.endsWith(':latest')) return [];
if (!image.endsWith(":latest")) return [];
return [
{
category: 'napcat-latest-image',
category: "napcat-latest-image",
file: context.file,
line: index + 1,
level: 'P3' as const,
message: 'NapCat 运行镜像使用 latest生产排查会受到镜像漂移影响。',
level: "P3" as const,
message: "NapCat 运行镜像使用 latest生产排查会受到镜像漂移影响。",
project: context.project,
suggestion: '改为明确版本 tag 或 digest并先在单账号容器观察稳定性后推广。',
suggestion:
"改为明确版本 tag 或 digest并先在单账号容器观察稳定性后推广。",
},
];
});
@ -193,11 +202,11 @@ export function findQqbotCommandServiceTestImportFindings(
project: string;
},
): ReviewFinding[] {
const normalized = context.file.replaceAll('\\', '/');
const normalized = context.file.replaceAll("\\", "/");
if (
context.project !== 'Node/kt-template-online-api' ||
!normalized.startsWith('test/qqbot/') ||
!normalized.endsWith('.ts')
context.project !== "Node/kt-template-online-api" ||
!normalized.startsWith("test/qqbot/") ||
!normalized.endsWith(".ts")
) {
return [];
}
@ -207,30 +216,32 @@ export function findQqbotCommandServiceTestImportFindings(
return [
{
category: 'qqbot-command-service-heavy-test-import',
category: "qqbot-command-service-heavy-test-import",
file: context.file,
line: index + 1,
level: 'P2' as const,
message: 'QQBot 测试直接导入 QqbotCommandService容易拖入插件注册和 BangDream runtime 导致单测卡住。',
level: "P2" as const,
message:
"QQBot 测试直接导入 QqbotCommandService容易拖入插件注册和 BangDream runtime 导致单测卡住。",
project: context.project,
suggestion: '把纯策略抽到轻量函数测试,或通过 Nest TestingModule 明确 mock 掉插件 registry/runtime 依赖。',
suggestion:
"把纯策略抽到轻量函数测试,或通过 Nest TestingModule 明确 mock 掉插件 registry/runtime 依赖。",
},
];
});
}
async function listGitTrackedFiles(projectPath: string): Promise<string[]> {
const tracked = await tryExecFile('git', ['ls-files'], projectPath);
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('\\', '/'));
.map((file) => file.replaceAll("\\", "/"));
}
function shouldReadReviewFile(file: string): boolean {
const normalized = file.replaceAll('\\', '/');
const normalized = file.replaceAll("\\", "/");
return !reviewSkipFilePattern.test(normalized);
}
@ -241,33 +252,37 @@ function readProjectFileLines(projectPath: string, file: string): string[] {
try {
const stats = statSync(filePath);
if (!stats.isFile() || stats.size > 1024 * 1024) return [];
return readFileSync(filePath, 'utf8').split(/\r?\n/);
return readFileSync(filePath, "utf8").split(/\r?\n/);
} catch {
return [];
}
}
function isBenignCredentialValue(value: string): boolean {
const normalized = value.trim().replace(/^["']|["']$/g, '').toLowerCase();
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;
if (normalized.startsWith("$") || normalized.startsWith("${")) return true;
if (normalized.startsWith("<") || normalized.includes("example")) return true;
if (isObviousPlaceholderCredentialValue(normalized)) return true;
if (/^\*+$/.test(normalized)) return true;
if (isLocalizedDisplayLabelValue(normalized)) return true;
return [
'false',
'hidden',
'masked',
'null',
'password',
'please-replace-me-with-your-own-key',
'redacted',
'replace-me',
'secret',
'token',
'true',
'undefined',
'your-token',
"false",
"hidden",
"masked",
"null",
"password",
"please-replace-me-with-your-own-key",
"redacted",
"replace-me",
"secret",
"token",
"true",
"undefined",
"your-token",
].includes(normalized);
}
@ -275,6 +290,17 @@ export function isBenignCredentialReviewValue(value: string): boolean {
return isBenignCredentialValue(value);
}
function isObviousPlaceholderCredentialValue(value: string): boolean {
return (
/^(test|mock|fake|dummy|demo)[-_]?(password|passwd|secret|token|key)?([_-]?\w*)?$/.test(
value,
) ||
/^(password|passwd|secret|token|key)[-_](test|mock|fake|dummy|demo|x)([_-]?\w*)?$/.test(
value,
)
);
}
function isLocalizedDisplayLabelValue(value: string): boolean {
return (
value.length <= 24 &&
@ -283,30 +309,45 @@ function isLocalizedDisplayLabelValue(value: string): boolean {
);
}
function isRuntimeDebugAllowed(projectAlias: ProjectAlias | null, file: string): boolean {
const normalized = file.replaceAll('\\', '/');
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/')) {
if (
projectAlias === "admin" &&
normalized.startsWith("internal/vite-config/")
) {
return true;
}
if (projectAlias === 'mcp' && /^(scripts\/|src\/server\.ts$|src\/selfTest\.ts$)/.test(normalized)) {
if (
projectAlias === "mcp" &&
/^(scripts\/|src\/server\.ts$|src\/selfTest\.ts$)/.test(normalized)
) {
return true;
}
if (projectAlias === 'fnosK8s' && /^(scripts\/|src\/server\.ts$)/.test(normalized)) {
if (
projectAlias === "fnosK8s" &&
/^(scripts\/|src\/server\.ts$)/.test(normalized)
) {
return true;
}
return false;
}
function isCredentialHintAllowed(file: string, line: string): boolean {
const normalized = file.replaceAll('\\', '/');
const normalized = file.replaceAll("\\", "/");
const trimmed = line.trimStart();
if (trimmed.startsWith('<')) return true;
if (trimmed.startsWith("<")) return true;
if (/\b(url|uri|dsn)\.password\s*=\s*['"]\*+['"]/.test(trimmed)) return true;
return /(^|\/)views\/(demos|examples)\//.test(normalized);
}
function isAllowedSensitiveTrackedFile(projectAlias: ProjectAlias | null, file: string): boolean {
function isAllowedSensitiveTrackedFile(
projectAlias: ProjectAlias | null,
file: string,
): boolean {
if (allowedSensitiveTrackedFilePattern.test(file)) return true;
if (projectAlias && frontendEnvProjectAliases.has(projectAlias)) {
return /(^|\/)\.env($|\..+)/i.test(file);
@ -335,38 +376,44 @@ function collectProjectContentFindings(
lines.forEach((line, index) => {
if (/^(<<<<<<<|=======|>>>>>>>) /.test(line)) {
findings.push({
category: 'conflict-marker',
category: "conflict-marker",
file,
line: index + 1,
level: 'P1',
message: '发现 Git 冲突标记。',
level: "P1",
message: "发现 Git 冲突标记。",
project: project.relativePath,
suggestion: '先解决冲突标记,再继续构建或提交。',
suggestion: "先解决冲突标记,再继续构建或提交。",
});
}
if (!isRuntimeDebugAllowed(project.alias, file) && /\bdebugger;|console\.log\(/.test(line)) {
if (
!isRuntimeDebugAllowed(project.alias, file) &&
/\bdebugger;|console\.log\(/.test(line)
) {
debugFindings.push({
category: 'runtime-debug',
category: "runtime-debug",
file,
line: index + 1,
level: /\bdebugger;/.test(line) ? 'P2' : 'P3',
message: '运行时代码中存在调试输出。',
level: /\bdebugger;/.test(line) ? "P2" : "P3",
message: "运行时代码中存在调试输出。",
project: project.relativePath,
suggestion: '页面或服务运行时代码不要保留 console.log/debugger。',
suggestion: "页面或服务运行时代码不要保留 console.log/debugger。",
});
}
if (includeContentScan) {
if (/\.(tsx|jsx|vue)$/.test(file) && /\bfunction\s+render[A-Z0-9_]/.test(line)) {
if (
/\.(tsx|jsx|vue)$/.test(file) &&
/\bfunction\s+render[A-Z0-9_]/.test(line)
) {
findings.push({
category: 'vue-tsx-render-helper',
category: "vue-tsx-render-helper",
file,
line: index + 1,
level: 'P2',
message: 'Vue TSX render helper 使用 function 声明。',
level: "P2",
message: "Vue TSX render helper 使用 function 声明。",
project: project.relativePath,
suggestion: '改为箭头函数,并在嵌套组件插槽中显式传 default。',
suggestion: "改为箭头函数,并在嵌套组件插槽中显式传 default。",
});
}
@ -378,13 +425,14 @@ function collectProjectContentFindings(
!isBenignCredentialValue(credentialMatch[3])
) {
credentialHints.push({
category: 'credential-literal',
category: "credential-literal",
file,
line: index + 1,
level: 'P2',
level: "P2",
message: `疑似 ${credentialMatch[1]} 字面量赋值。`,
project: project.relativePath,
suggestion: '确认是否为真实凭据;真实值应移入本地 env 或远程 Secret。',
suggestion:
"确认是否为真实凭据;真实值应移入本地 env 或远程 Secret。",
});
}
}
@ -418,7 +466,10 @@ function collectProjectContentFindings(
};
}
function findWorkspaceFilesByName(names: Set<string>, maxResults: number): string[] {
function findWorkspaceFilesByName(
names: Set<string>,
maxResults: number,
): string[] {
const results: string[] = [];
const walk = (currentPath: string) => {
@ -433,7 +484,11 @@ function findWorkspaceFilesByName(names: Set<string>, maxResults: number): strin
}
if (entry.isFile() && names.has(entry.name)) {
results.push(toPosix(path.relative(workspaceRoot, path.join(currentPath, entry.name))));
results.push(
toPosix(
path.relative(workspaceRoot, path.join(currentPath, entry.name)),
),
);
}
}
};
@ -453,25 +508,33 @@ function findRootGeneratedArtifacts(): string[] {
async function reviewProject(
projectKey: string,
input: Required<
Pick<GlobalCodeReviewInput, 'contentScanMode' | 'includeContentScan' | 'maxFindingsPerProject'>
Pick<
GlobalCodeReviewInput,
"contentScanMode" | "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: '' };
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 trackedFiles =
repoType === "git" ? await listGitTrackedFiles(project.path) : [];
const trackedFileSet = new Set(trackedFiles);
const contentScannedFiles =
input.contentScanMode === 'all'
input.contentScanMode === "all"
? trackedFiles
: changedFiles
.map((file) => file.replaceAll('\\', '/'))
.map((file) => file.replaceAll("\\", "/"))
.filter((file) => shouldReadReviewFile(file))
.filter((file) => trackedFileSet.has(file) || existsSync(path.join(project.path, file)));
.filter(
(file) =>
trackedFileSet.has(file) ||
existsSync(path.join(project.path, file)),
);
const trackedSensitiveFiles = trackedFiles.filter(
(file) =>
sensitiveTrackedFilePattern.test(file) &&
@ -486,12 +549,13 @@ async function reviewProject(
const trackedSensitiveFindings = trackedSensitiveFiles
.slice(0, input.maxFindingsPerProject)
.map<ReviewFinding>((file) => ({
category: 'tracked-sensitive-file',
category: "tracked-sensitive-file",
file,
level: 'P1',
message: '仓库仍跟踪真实环境或部署凭据类文件。',
level: "P1",
message: "仓库仍跟踪真实环境或部署凭据类文件。",
project: project.relativePath,
suggestion: '使用 git rm --cached 移出索引,并提交 example 文件或文档说明。',
suggestion:
"使用 git rm --cached 移出索引,并提交 example 文件或文档说明。",
}));
return {
@ -519,7 +583,7 @@ export async function buildGlobalCodeReview(
? input.projects!
: [...defaultReviewProjects];
const includeContentScan = input.includeContentScan ?? true;
const contentScanMode = input.contentScanMode ?? 'changed';
const contentScanMode = input.contentScanMode ?? "changed";
const maxFindingsPerProject = Math.max(
1,
Math.min(input.maxFindingsPerProject ?? 20, 200),
@ -536,34 +600,39 @@ export async function buildGlobalCodeReview(
const rootLegacyFiles =
input.includeRootScan === false
? []
: findWorkspaceFilesByName(new Set(['deploy.js', 'deploy.json']), 50);
: findWorkspaceFilesByName(new Set(["deploy.js", "deploy.json"]), 50);
const rootGeneratedArtifacts =
input.includeRootScan === false ? [] : findRootGeneratedArtifacts();
const taskRecordFindings =
input.includeRootScan === false
? []
: findTaskRecordGovernanceFindings(readProjectFileLines(workspaceRoot, 'TASKS.md'));
: findTaskRecordGovernanceFindings(
readProjectFileLines(workspaceRoot, "TASKS.md"),
);
const findings = projectResults.flatMap((item) => [
...item.findings,
...item.debugFindings,
...item.credentialHints,
]);
const rootFindings = rootLegacyFiles.map<ReviewFinding>((file) => ({
category: 'legacy-deploy-file',
category: "legacy-deploy-file",
file,
level: 'P2',
message: '发现旧部署遗留文件。',
project: 'root',
suggestion: '确认是否仍被脚本引用;无用则删除并从 Git 索引移除。',
level: "P2",
message: "发现旧部署遗留文件。",
project: "root",
suggestion: "确认是否仍被脚本引用;无用则删除并从 Git 索引移除。",
}));
const rootGeneratedFindings = rootGeneratedArtifacts.map<ReviewFinding>((file) => ({
category: 'root-generated-artifact',
const rootGeneratedFindings = rootGeneratedArtifacts.map<ReviewFinding>(
(file) => ({
category: "root-generated-artifact",
file,
level: 'P2',
message: '根目录存在生成态临时产物。',
project: 'root',
suggestion: '统一迁入 .kt-workspace 对应子目录,并更新生成脚本或测试流程。',
}));
level: "P2",
message: "根目录存在生成态临时产物。",
project: "root",
suggestion:
"统一迁入 .kt-workspace 对应子目录,并更新生成脚本或测试流程。",
}),
);
const allFindings = [
...findings,
...rootFindings,
@ -575,11 +644,11 @@ export async function buildGlobalCodeReview(
findings: allFindings,
generatedAt: new Date().toISOString(),
notes: [
'本工具只读扫描,不会修改文件、删除文件或提交代码。',
'P1 优先处理:冲突标记、被跟踪的真实 env/部署凭据文件。',
'默认 contentScanMode=changed只对当前变更文件做 P2/P3 内容扫描,并放过短中文显示标签,避免历史误报污染上下文;需要全仓库深扫时传 all。',
'P2 需要人工确认:疑似凭据字面量、旧部署文件、调试断点。',
'P3 为清洁度问题:运行时 console.log 等。',
"本工具只读扫描,不会修改文件、删除文件或提交代码。",
"P1 优先处理:冲突标记、被跟踪的真实 env/部署凭据文件。",
"默认 contentScanMode=changed只对当前变更文件做 P2/P3 内容扫描,并放过短中文显示标签,避免历史误报污染上下文;需要全仓库深扫时传 all。",
"P2 需要人工确认:疑似凭据字面量、旧部署文件、调试断点。",
"P3 为清洁度问题:运行时 console.log 等。",
],
projects: projectResults.map((item) => ({
changedFiles: item.changedFiles,
@ -594,13 +663,14 @@ export async function buildGlobalCodeReview(
trackedSensitiveFiles: item.trackedSensitiveFiles,
})),
summary: {
changedProjectCount: projectResults.filter((item) => item.changedFiles.length > 0)
.length,
changedProjectCount: projectResults.filter(
(item) => item.changedFiles.length > 0,
).length,
contentScanMode,
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,
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,
rootGeneratedArtifacts,
rootLegacyFiles,