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 的防踩坑清单和验证点。 - 生成专项组件工作流KtTable、BlogArgon、AdminAuth、QQBot、FF14Plugin、NapCatLogin、SystemLog、Knife4jSwagger、FnosK8s 的防踩坑清单和验证点。
- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。 - 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。
- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮模板目录永久保留CLI 默认 dry-run真实清理必须显式传 `--execute` - 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮模板目录永久保留CLI 默认 dry-run真实清理必须显式传 `--execute`
- 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、部署链路和 Vue TSX 插槽写法等高风险改动。 - 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、根目录生成产物、`TASKS.md` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签,避免历史误报污染上下文;任何文件改动后都要跑一遍。 - 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、根目录生成产物、`TASKS.md` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token,避免历史误报污染上下文;任何文件改动后都要跑一遍。
- 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。 - 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。
- 生成提交前检查清单:校验 KT commit message 约定。 - 生成提交前检查清单:校验 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_read_context` | 读取 KT 根目录紧凑上下文和最近任务记录,支持显式读取完整文档 |
| `kt_inspect_project` | 检查子项目仓库、包管理器、Node、env 和 Git 状态 | | `kt_inspect_project` | 检查子项目仓库、包管理器、Node、env 和 Git 状态 |
| `kt_inspect_all_projects` | 一次性检查所有 KT 项目,适合多仓库联动任务 | | `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 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-context` | 输出 Obsidian 索引上下文;可传 `--module Admin`、`--query QQBot`、`--max-documents 10`。 |
| `pnpm run obsidian-validate` | 校验 KT Obsidian vault 结构和链接;默认 warning 不让脚本失败,需要严格模式时传 `--fail-on-warnings`。 | | `pnpm run obsidian-validate` | 校验 KT Obsidian vault 结构和链接;默认 warning 不让脚本失败,需要严格模式时传 `--fail-on-warnings`。 |
| `pnpm run obsidian-sync` | 审计 Obsidian 工作流入口是否连通,并联动执行 validate。 | | `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` | | `root` | `D:/MyFiles/KT` |
| `mcp` | `mcp/ktWorkflow` | | `mcp` | `mcp/ktWorkflow` |
| `api` | `Node/kt-template-online-api` | | `api` | `Node/kt-template-online-api` |
@ -168,6 +168,6 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
## 来源与许可证 ## 来源与许可证
| 一级来源 | 使用方式 | License | | 一级来源 | 使用方式 | License |
| --- | --- | --- | | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------- |
| [multica-ai/andrej-karpathy-skills](https://github.com/multica-ai/andrej-karpathy-skills) | `skills/karpathy-guidelines` 和 ktWorkflow 防偏差闭环的行为准则来源 | MIT | | [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 | | [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 { mkdirSync, writeFileSync } from "node:fs";
import path from 'node:path'; import path from "node:path";
import { parseGlobalReviewCliArgs } from './core/cli.js'; import { parseGlobalReviewCliArgs } from "./core/cli.js";
import { registeredToolNames } from './core/constants.js'; import { registeredToolNames } from "./core/constants.js";
import { buildBlockerResolution } from './tools/blocker.js'; import { buildBlockerResolution } from "./tools/blocker.js";
import { buildChangeDocSync } from './tools/docSync.js'; import { buildChangeDocSync } from "./tools/docSync.js";
import { resolveInsideRoot } from './core/workspace.js'; import { resolveInsideRoot } from "./core/workspace.js";
import { buildGuardrails } from './tools/guardrails.js'; import { hasEntityLoadTimeSerializationMutation } from "./tools/envRisk.js";
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js'; import { buildGuardrails } from "./tools/guardrails.js";
import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js'; import {
import { buildWorkflowLoopAudit } from './tools/loop.js'; readObsidianContext,
import { inspectProject } from './tools/inspect.js'; syncObsidianWorkflow,
import { prepareTask, readWorkflowContext } from './tools/task.js'; validateObsidianVault,
import { buildGlobalCodeReview, findNapcatImageGovernanceFindings, findQqbotCommandServiceTestImportFindings, findTaskRecordGovernanceFindings, isBenignCredentialReviewValue } from './tools/review.js'; } from "./tools/obsidian.js";
import { buildBusinessTestPlan } from './tools/testing.js'; import {
import { buildVerificationPlan } from './tools/verification.js'; cleanupHistoryArtifacts,
import { buildWorkstreamCloseout } from './tools/closeout.js'; parseCliCleanupArgs,
import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js'; } 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> { export async function runSelfTest(): Promise<void> {
const reviewClassifier = { const reviewClassifier = {
localizedSecretLabelAllowed: isBenignCredentialReviewValue('密钥'), localizedSecretLabelAllowed: isBenignCredentialReviewValue("密钥"),
realTokenRejected: !isBenignCredentialReviewValue('eqc2Sbp8ufP2AcS6I2YReLqxnu3XJKC6D7C6PUcx'), realTokenRejected: !isBenignCredentialReviewValue(
"eqc2Sbp8ufP2AcS6I2YReLqxnu3XJKC6D7C6PUcx",
),
testTokenAllowed: isBenignCredentialReviewValue("token-test"),
}; };
if (!reviewClassifier.localizedSecretLabelAllowed || !reviewClassifier.realTokenRejected) { if (
throw new Error('review credential classifier self-check failed'); !reviewClassifier.localizedSecretLabelAllowed ||
!reviewClassifier.realTokenRejected ||
!reviewClassifier.testTokenAllowed
) {
throw new Error("review credential classifier self-check failed");
} }
const taskRecordFindings = findTaskRecordGovernanceFindings([ const taskRecordFindings = findTaskRecordGovernanceFindings([
'## 最近记录', "## 最近记录",
'### 2026-06-10长记录污染', "### 2026-06-10长记录污染",
'- 范围root', "- 范围root",
'- 关键词TASKS 治理', "- 关键词TASKS 治理",
'- 页面测试用例:配置类任务,无页面测试。', "- 页面测试用例:配置类任务,无页面测试。",
'- 验证global-review', "- 验证global-review",
'## 历史索引', "## 历史索引",
]); ]);
if (!taskRecordFindings.some((item) => item.category === 'tasks-record-extra-field')) { if (
throw new Error('TASKS record governance self-check failed'); !taskRecordFindings.some(
(item) => item.category === "tasks-record-extra-field",
)
) {
throw new Error("TASKS record governance self-check failed");
} }
const napcatImageFindings = findNapcatImageGovernanceFindings( const napcatImageFindings = findNapcatImageGovernanceFindings(
['QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest'], ["QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest"],
{ {
file: '.env.example', file: ".env.example",
project: 'Node/kt-template-online-api', project: "Node/kt-template-online-api",
}, },
); );
if (!napcatImageFindings.some((item) => item.category === 'napcat-latest-image')) { if (
throw new Error('NapCat image governance self-check failed'); !napcatImageFindings.some((item) => item.category === "napcat-latest-image")
) {
throw new Error("NapCat image governance self-check failed");
} }
const qqbotCommandServiceImportFindings = findQqbotCommandServiceTestImportFindings( const qqbotCommandServiceImportFindings =
["import { QqbotCommandService } from '@/qqbot/command/qqbot-command.service';"], findQqbotCommandServiceTestImportFindings(
[
"import { QqbotCommandService } from '@/qqbot/command/qqbot-command.service';",
],
{ {
file: 'test/qqbot/command/qqbot-command-cooldown.service.spec.ts', file: "test/qqbot/command/qqbot-command-cooldown.service.spec.ts",
project: 'Node/kt-template-online-api', project: "Node/kt-template-online-api",
}, },
); );
if ( if (
!qqbotCommandServiceImportFindings.some( !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 = { const reviewCliParser = {
dashedAll: parseGlobalReviewCliArgs(['node', 'server', '--global-review', '--content-scan-all']).contentScanMode, dashedAll: parseGlobalReviewCliArgs([
keyValueAll: parseGlobalReviewCliArgs(['node', 'server', '--global-review', '--contentScanMode=all']).contentScanMode, "node",
splitAll: parseGlobalReviewCliArgs(['node', 'server', '--global-review', '--content-scan-mode', 'all']).contentScanMode, "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 ( if (
reviewCliParser.dashedAll !== 'all' || reviewCliParser.dashedAll !== "all" ||
reviewCliParser.keyValueAll !== 'all' || reviewCliParser.keyValueAll !== "all" ||
reviewCliParser.splitAll !== '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({ const refactorGuardrails = buildGuardrails({
project: 'mcp', project: "mcp",
taskType: 'refactor', taskType: "refactor",
userRequest: '重构 KT 工作流护栏', userRequest: "重构 KT 工作流护栏",
}); });
const refactorGuardrailOk = const refactorGuardrailOk =
refactorGuardrails.guardrails.beforeEdit.some((item) => item.includes('重构前写清楚')) && refactorGuardrails.guardrails.beforeEdit.some((item) =>
refactorGuardrails.guardrails.verification.notes.some((item) => item.includes('重构验证必须覆盖原行为')); item.includes("重构前写清楚"),
) &&
refactorGuardrails.guardrails.verification.notes.some((item) =>
item.includes("重构验证必须覆盖原行为"),
);
if (!refactorGuardrailOk) { if (!refactorGuardrailOk) {
throw new Error('refactor guardrail self-check failed'); throw new Error("refactor guardrail self-check failed");
} }
const apiVerification = buildVerificationPlan({ const apiVerification = buildVerificationPlan({
changeType: 'api', changeType: "api",
project: 'api', project: "api",
}); });
const apiVerificationCommands = apiVerification.commands.join('\n'); const apiVerificationCommands = apiVerification.commands.join("\n");
if ( if (
!apiVerificationCommands.includes('pnpm exec jest --runInBand') || !apiVerificationCommands.includes("pnpm exec jest --runInBand") ||
apiVerificationCommands.includes('passWithNoTests') 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({ const databaseVerification = buildVerificationPlan({
changeType: 'database', changeType: "database",
project: 'api', project: "api",
}); });
const databaseVerificationNotes = databaseVerification.notes.join('\n'); const databaseVerificationNotes = databaseVerification.notes.join("\n");
if ( if (
!databaseVerificationNotes.includes('备份目标表') || !databaseVerificationNotes.includes("备份目标表") ||
!databaseVerificationNotes.includes('SHOW COLUMNS') || !databaseVerificationNotes.includes("SHOW COLUMNS") ||
!databaseVerificationNotes.includes('真实接口 smoke') !databaseVerificationNotes.includes("真实接口 smoke")
) { ) {
throw new Error('database migration verification self-check failed'); throw new Error("database migration verification self-check failed");
} }
const closeoutNeedsUpgrade = buildWorkstreamCloseout({ const closeoutNeedsUpgrade = buildWorkstreamCloseout({
cleanupEvidence: ['cleanup-history dry-run deleted=0。'], cleanupEvidence: ["cleanup-history dry-run deleted=0。"],
docSyncEvidence: ['kt_change_doc_sync requiredDocs 已处理。'], docSyncEvidence: ["kt_change_doc_sync requiredDocs 已处理。"],
reviewEvidence: ['global-review findings=0。'], reviewEvidence: ["global-review findings=0。"],
problemRecords: ['Jenkins 状态需要结合日志和 K8s 状态确认。'], problemRecords: ["Jenkins 状态需要结合日志和 K8s 状态确认。"],
reusablePatterns: ['deploy-observation'], reusablePatterns: ["deploy-observation"],
stableSolutions: ['收尾时固定观测 build、commit、镜像、Deployment、Pod 和 smoke。'], stableSolutions: [
title: '发布闭环', "收尾时固定观测 build、commit、镜像、Deployment、Pod 和 smoke。",
verificationEvidence: ['Jenkins #132 SUCCESSK8s Pod Running。'], ],
title: "发布闭环",
verificationEvidence: ["Jenkins #132 SUCCESSK8s Pod Running。"],
}); });
if ((closeoutNeedsUpgrade.canReportComplete as boolean) !== false) { 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({ const closeoutComplete = buildWorkstreamCloseout({
cleanupEvidence: ['cleanup-history dry-run deleted=0。'], cleanupEvidence: ["cleanup-history dry-run deleted=0。"],
docSyncEvidence: ['无需文档更新,已记录原因。'], docSyncEvidence: ["无需文档更新,已记录原因。"],
ktWorkflowUpdated: true, ktWorkflowUpdated: true,
problemRecords: ['无新卡点。'], problemRecords: ["无新卡点。"],
reviewEvidence: ['global-review findings=0。'], reviewEvidence: ["global-review findings=0。"],
reusablePatterns: ['none'], reusablePatterns: ["none"],
stableSolutions: ['无新增稳定解法。'], stableSolutions: ["无新增稳定解法。"],
title: '文档收尾', title: "文档收尾",
verificationEvidence: ['typecheck/self-test/global-review 通过。'], verificationEvidence: ["typecheck/self-test/global-review 通过。"],
}); });
if ((closeoutComplete.canReportComplete as boolean) !== true) { 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({ const closeoutInvalidCleanup = buildWorkstreamCloseout({
cleanupEvidence: ['cleanup-history dry-run deleted=3。'], cleanupEvidence: ["cleanup-history dry-run deleted=3。"],
docSyncEvidence: ['kt_change_doc_sync requiredDocs 已处理。'], docSyncEvidence: ["kt_change_doc_sync requiredDocs 已处理。"],
problemRecords: ['无新卡点。'], problemRecords: ["无新卡点。"],
reviewEvidence: ['global-review findings=0。'], reviewEvidence: ["global-review findings=0。"],
reusablePatterns: ['none'], reusablePatterns: ["none"],
stableSolutions: ['无新增稳定解法。'], stableSolutions: ["无新增稳定解法。"],
title: '无效清理证据', title: "无效清理证据",
verificationEvidence: ['typecheck 通过。'], verificationEvidence: ["typecheck 通过。"],
}); });
if ((closeoutInvalidCleanup.canReportComplete as boolean) !== false) { 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({ const obsidianContext = readObsidianContext({
maxDocuments: 6, maxDocuments: 6,
module: 'ktWorkflow', module: "ktWorkflow",
}); });
if ((obsidianContext.summary as { returnedDocuments?: number }).returnedDocuments === 0) { if (
throw new Error('obsidian context self-check failed'); (obsidianContext.summary as { returnedDocuments?: number })
.returnedDocuments === 0
) {
throw new Error("obsidian context self-check failed");
} }
if ((obsidianContext.summary as { codeAnchors?: number }).codeAnchors === 0) { 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({ const obsidianValidation = validateObsidianVault({
includeStaleReferences: false, includeStaleReferences: false,
}); });
if ((obsidianValidation.summary as { errors?: number }).errors !== 0) { 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 = { const cleanupCliParser = {
defaultDryRun: parseCliCleanupArgs(['node', 'server', '--cleanup-history']).dryRun, defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"])
executeDryRun: parseCliCleanupArgs(['node', 'server', '--cleanup-history', '--execute']).dryRun, .dryRun,
executeDryRun: parseCliCleanupArgs([
"node",
"server",
"--cleanup-history",
"--execute",
]).dryRun,
}; };
if (!cleanupCliParser.defaultDryRun || cleanupCliParser.executeDryRun) { 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({ const cleanupPreview = cleanupHistoryArtifacts({
dryRun: true, dryRun: true,
keep: 3, keep: 3,
roots: ['.kt-workspace/test-artifacts', '.kt-workspace'], roots: [".kt-workspace/test-artifacts", ".kt-workspace"],
}); });
const cleanupRoots = cleanupPreview.roots as Array<{ const cleanupRoots = cleanupPreview.roots as Array<{
preservedNames: string[]; preservedNames: string[];
root: string; root: string;
}>; }>;
const testArtifactsRoot = cleanupRoots.find((item) => item.root === '.kt-workspace/test-artifacts'); const testArtifactsRoot = cleanupRoots.find(
const workspaceHistoryRoot = cleanupRoots.find((item) => item.root === '.kt-workspace'); (item) => item.root === ".kt-workspace/test-artifacts",
if (!testArtifactsRoot?.preservedNames.includes('_templates')) { );
throw new Error('cleanup template preservation self-check failed'); 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')) { if (!workspaceHistoryRoot?.preservedNames.includes("test-artifacts")) {
throw new Error('cleanup workspace root preservation self-check failed'); throw new Error("cleanup workspace root preservation self-check failed");
} }
const docSyncBangDream = await buildChangeDocSync({ const docSyncBangDream = await buildChangeDocSync({
changedFiles: ['src/qqbot/plugins/bangDream/application/bangdream-application.service.ts'], changedFiles: [
project: 'api', "src/qqbot/plugins/bangDream/application/bangdream-application.service.ts",
],
project: "api",
}); });
const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{ path: string }>; const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{
if (!docSyncRequiredDocs.some((item) => item.path === 'Node/kt-template-online-api/API.md')) { path: string;
throw new Error('doc sync API self-check failed'); }>;
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({ const loopAuditComplete = buildWorkflowLoopAudit({
cleanupExecuted: true, cleanupExecuted: true,
cleanupFinalDeleted: 0, cleanupFinalDeleted: 0,
cleanupPreviewDeleted: 3, cleanupPreviewDeleted: 3,
docSyncEvidence: ['kt_change_doc_sync requiredDocs 已处理。'], docSyncEvidence: ["kt_change_doc_sync requiredDocs 已处理。"],
ktWorkflowUpdated: true, ktWorkflowUpdated: true,
problemRecords: ['测试历史清理流程未自动执行。'], problemRecords: ["测试历史清理流程未自动执行。"],
reusablePattern: true, reusablePattern: true,
reviewEvidence: ['global-review findings=0'], reviewEvidence: ["global-review findings=0"],
stableSolutions: ['cleanup-history 默认 dry-run显式 --execute 后复验 deleted=0。'], stableSolutions: [
taskTitle: '自动化闭环审计', "cleanup-history 默认 dry-run显式 --execute 后复验 deleted=0。",
testCases: ['cleanup-history dry-run/execute 回归测试'], ],
verificationEvidence: ['typecheck/self-test/cleanup dry-run 通过'], taskTitle: "自动化闭环审计",
testCases: ["cleanup-history dry-run/execute 回归测试"],
verificationEvidence: ["typecheck/self-test/cleanup dry-run 通过"],
}); });
if ((loopAuditComplete.canReportComplete as boolean) !== true) { 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({ const loopAuditBlocked = buildWorkflowLoopAudit({
cleanupPreviewDeleted: 1, cleanupPreviewDeleted: 1,
taskTitle: '缺证据闭环', taskTitle: "缺证据闭环",
}); });
if ((loopAuditBlocked.canReportComplete as boolean) !== false) { 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({ const loopAuditMissingCleanup = buildWorkflowLoopAudit({
docSyncEvidence: ['kt_change_doc_sync requiredDocs 已处理。'], docSyncEvidence: ["kt_change_doc_sync requiredDocs 已处理。"],
reviewEvidence: ['global-review findings=0。'], reviewEvidence: ["global-review findings=0。"],
taskTitle: '缺清理证据闭环', taskTitle: "缺清理证据闭环",
testCases: ['self-test'], testCases: ["self-test"],
verificationEvidence: ['typecheck 通过。'], verificationEvidence: ["typecheck 通过。"],
}); });
if ((loopAuditMissingCleanup.canReportComplete as boolean) !== false) { 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 dbSyncPlan = buildDbSyncPlan({});
const dbSyncCommands = (dbSyncPlan.commands as string[]).join('\n'); const dbSyncCommands = (dbSyncPlan.commands as string[]).join("\n");
const backupName = dbSyncPlan.backupName as string; const backupName = dbSyncPlan.backupName as string;
if ( if (
!backupName.includes('_') || !backupName.includes("_") ||
!dbSyncCommands.includes('.kt-workspace/db-sync') || !dbSyncCommands.includes(".kt-workspace/db-sync") ||
dbSyncCommands.includes('CREATE DATABASE IF NOT EXISTS') 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 = { const data = {
context: readWorkflowContext({ taskRecordCount: 2 }), context: readWorkflowContext({ taskRecordCount: 2 }),
guardrails: buildGuardrails({ guardrails: buildGuardrails({
project: 'mcp', project: "mcp",
taskType: 'mcp', taskType: "mcp",
userRequest: '扩展 KT 工作区 MCP 可复用能力', userRequest: "扩展 KT 工作区 MCP 可复用能力",
}), }),
cleanupCliParser, cleanupCliParser,
cleanupPreview, cleanupPreview,
@ -275,26 +373,29 @@ export async function runSelfTest(): Promise<void> {
dryRun: true, dryRun: true,
keep: 3, keep: 3,
}), }),
inspectAdmin: await inspectProject({ includeGit: false, project: 'admin' }), inspectAdmin: await inspectProject({ includeGit: false, project: "admin" }),
inspectKnife4j: await inspectProject({ includeGit: false, project: 'knife4j' }), inspectKnife4j: await inspectProject({
includeGit: false,
project: "knife4j",
}),
prepareTask: await prepareTask({ prepareTask: await prepareTask({
includeGit: false, includeGit: false,
project: 'mcp', project: "mcp",
taskType: 'mcp', taskType: "mcp",
userRequest: '扩展 KT 工作区 MCP 可复用能力', userRequest: "扩展 KT 工作区 MCP 可复用能力",
}), }),
review: await buildGlobalCodeReview({ review: await buildGlobalCodeReview({
includeContentScan: true, includeContentScan: true,
includeRootScan: true, includeRootScan: true,
maxFindingsPerProject: 10, maxFindingsPerProject: 10,
projects: ['mcp'], projects: ["mcp"],
}), }),
reviewCliParser, reviewCliParser,
reviewClassifier, reviewClassifier,
refactorGuardrails, refactorGuardrails,
apiVerification, apiVerification,
businessTestPlan: buildBusinessTestPlan({ businessTestPlan: buildBusinessTestPlan({
flow: 'system-log-visualization', flow: "system-log-visualization",
}), }),
closeoutComplete, closeoutComplete,
closeoutNeedsUpgrade, closeoutNeedsUpgrade,
@ -305,41 +406,47 @@ export async function runSelfTest(): Promise<void> {
obsidianValidation, obsidianValidation,
blockerResolution: buildBlockerResolution({ blockerResolution: buildBlockerResolution({
attempts: 2, attempts: 2,
evidence: 'self-test', evidence: "self-test",
problem: '重复超时', problem: "重复超时",
project: 'mcp', project: "mcp",
solution: '改用固定脚本和 timeout 后再验证', solution: "改用固定脚本和 timeout 后再验证",
}), }),
commitPlan: await buildCommitPlan({ commitPlan: await buildCommitPlan({
projects: ['mcp'], projects: ["mcp"],
}), }),
componentWorkflow: buildComponentWorkflow({ componentWorkflow: buildComponentWorkflow({
target: 'SystemLog', target: "SystemLog",
}), }),
dbSyncPlan, dbSyncPlan,
finishTask: await buildFinishTask({ finishTask: await buildFinishTask({
cleanupHistory: false, cleanupHistory: false,
projects: ['mcp'], projects: ["mcp"],
runValidation: false, runValidation: false,
}), }),
pushPlan: await buildPushPlan({ pushPlan: await buildPushPlan({
projects: ['mcp'], projects: ["mcp"],
}), }),
remoteHealthCheck: await buildRemoteHealthCheck({ remoteHealthCheck: await buildRemoteHealthCheck({
execute: false, execute: false,
services: ['ssh', 'docker'], services: ["ssh", "docker"],
}), }),
verification: buildVerificationPlan({ verification: buildVerificationPlan({
changeType: 'mcp', changeType: "mcp",
project: 'mcp', project: "mcp",
}), }),
}; };
const outputDir = resolveInsideRoot('.kt-workspace/verify/ktWorkflow'); const outputDir = resolveInsideRoot(".kt-workspace/verify/ktWorkflow");
mkdirSync(outputDir, { recursive: true }); mkdirSync(outputDir, { recursive: true });
writeFileSync( writeFileSync(
path.join(outputDir, 'self-test.json'), path.join(outputDir, "self-test.json"),
JSON.stringify(data, null, 2), 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 { existsSync, readFileSync, statSync } from "node:fs";
import path from 'node:path'; import path from "node:path";
import type { ProjectAlias } from '../types.js'; import type { ProjectAlias } from "../types.js";
import type { EnvPolicyInput, RiskScanInput } from '../types.js'; import type { EnvPolicyInput, RiskScanInput } from "../types.js";
import { tryExecFile } from '../core/exec.js'; import { tryExecFile } from "../core/exec.js";
import { detectRepoType, parseGitStatusFiles } from '../core/project.js'; import { detectRepoType, parseGitStatusFiles } from "../core/project.js";
import { readTextIfExists, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js'; import {
readTextIfExists,
resolveProject,
toPosix,
workspaceRoot,
} from "../core/workspace.js";
const frontendEnvProjectAliases = new Set<ProjectAlias>([ const frontendEnvProjectAliases = new Set<ProjectAlias>([
'admin', "admin",
'blog', "blog",
'playground', "playground",
'web', "web",
]); ]);
function allowsTrackedFrontendEnv(projectAlias: ProjectAlias | null): boolean { function allowsTrackedFrontendEnv(projectAlias: ProjectAlias | null): boolean {
@ -19,29 +24,60 @@ function allowsTrackedFrontendEnv(projectAlias: ProjectAlias | null): boolean {
} }
function readSmallFileIfExists(filePath: string): string { function readSmallFileIfExists(filePath: string): string {
if (!existsSync(filePath)) return ''; if (!existsSync(filePath)) return "";
try { try {
const stats = statSync(filePath); const stats = statSync(filePath);
if (!stats.isFile() || stats.size > 512 * 1024) return ''; if (!stats.isFile() || stats.size > 512 * 1024) return "";
return readFileSync(filePath, 'utf8'); return readFileSync(filePath, "utf8");
} catch { } 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> { export function buildEnvPolicy(input: EnvPolicyInput): Record<string, unknown> {
const project = resolveProject(input.project); const project = resolveProject(input.project);
const frontendEnvAllowed = allowsTrackedFrontendEnv(project.alias); 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); const absolutePath = path.join(project.path, file);
return { return {
exists: existsSync(absolutePath), exists: existsSync(absolutePath),
file, file,
relativePath: toPosix(path.relative(workspaceRoot, absolutePath)), 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 const ignoredRules = gitignore
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => line.trim()) .map((line) => line.trim())
@ -49,31 +85,40 @@ export function buildEnvPolicy(input: EnvPolicyInput): Record<string, unknown> {
return { return {
envFiles, envFiles,
ignoredRules: ignoredRules.filter((line) => line.includes('.env')), ignoredRules: ignoredRules.filter((line) => line.includes(".env")),
policy: frontendEnvAllowed policy: frontendEnvAllowed
? [ ? [
'前端项目的 .env.development/.env.production 可以跟随仓库,用于客户端构建变量。', "前端项目的 .env.development/.env.production 可以跟随仓库,用于客户端构建变量。",
'前端 env 仍不得写入服务端密钥、数据库密码、WordPress 管理员密码、MinIO Secret 或 token。', "前端 env 仍不得写入服务端密钥、数据库密码、WordPress 管理员密码、MinIO Secret 或 token。",
'.env.example 继续维护变量名和示例值。', ".env.example 继续维护变量名和示例值。",
] ]
: [ : [
'真实开发和生产配置只保留在本地,不提交。', "真实开发和生产配置只保留在本地,不提交。",
'.env.example 用于维护变量名和示例值。', ".env.example 用于维护变量名和示例值。",
'接口、数据库、WordPress、MinIO 等真实账号密码不得写入文档或提交记录。', "接口、数据库、WordPress、MinIO 等真实账号密码不得写入文档或提交记录。",
], ],
project, project,
warnings: envFiles 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 忽略。`), .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); const project = resolveProject(input.project);
let changedFiles = input.changedFiles || []; let changedFiles = input.changedFiles || [];
if (changedFiles.length === 0 && detectRepoType(project.path) === 'git') { if (changedFiles.length === 0 && detectRepoType(project.path) === "git") {
const status = await tryExecFile('git', ['status', '--short'], project.path); const status = await tryExecFile(
"git",
["status", "--short"],
project.path,
);
if (status.ok) { if (status.ok) {
changedFiles = parseGitStatusFiles(status.stdout); changedFiles = parseGitStatusFiles(status.stdout);
} }
@ -81,12 +126,12 @@ export async function scanTaskRisk(input: RiskScanInput): Promise<Record<string,
const risks: Array<{ const risks: Array<{
file: string; file: string;
level: 'high' | 'medium'; level: "high" | "medium";
reason: string; reason: string;
suggestion: string; suggestion: string;
}> = []; }> = [];
const addRisk = ( const addRisk = (
level: 'high' | 'medium', level: "high" | "medium",
file: string, file: string,
reason: string, reason: string,
suggestion: string, suggestion: string,
@ -95,119 +140,175 @@ export async function scanTaskRisk(input: RiskScanInput): Promise<Record<string,
}; };
for (const file of changedFiles) { for (const file of changedFiles) {
const normalized = file.replaceAll('\\', '/'); const normalized = file.replaceAll("\\", "/");
if (/(^|\/)\.env($|\..+)/.test(normalized)) { if (/(^|\/)\.env($|\..+)/.test(normalized)) {
if (allowsTrackedFrontendEnv(project.alias)) { if (allowsTrackedFrontendEnv(project.alias)) {
addRisk( addRisk(
'medium', "medium",
file, file,
'前端构建环境文件有改动。', "前端构建环境文件有改动。",
'确认只包含客户端公开配置,不包含服务端密钥、数据库密码或 token。', "确认只包含客户端公开配置,不包含服务端密钥、数据库密码或 token。",
); );
} else { } 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( addRisk(
'medium', "high",
file, 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)) { if (normalized.includes("/components/ktTable/")) {
addRisk('medium', file, '后端接口或业务逻辑改动。', '本地启动或复用服务后真实调用对应接口。');
}
if (/src\/common\/decorators\/format-date-time\.decorator\.ts|src\/.*\.(entity|dto)\.ts/.test(normalized)) {
addRisk( addRisk(
'medium', "medium",
file, file,
'后端时间字段或 DTO/Entity 响应结构改动。', "KtTable 是高频核心组件,布局和滚动性能容易回归。",
'确认数据源经 DTO/Entity 实例返回FormatDateTime 不做递归响应遍历,时间统一 YYYY-MM-DD HH:mm:ss。', "保持原生 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( addRisk(
'medium', "medium",
file, 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)) { if (/src\/qqbot\/plugins\/(ff14Market|fflogs)\//i.test(normalized)) {
addRisk( addRisk(
'medium', "medium",
file, file,
'FF14/FFLogs 命令插件改动。', "FF14/FFLogs 命令插件改动。",
'确认中文查询和回显走字典映射,命令注册表同步,真实命令输出字段完整。', "确认中文查询和回显走字典映射,命令注册表同步,真实命令输出字段完整。",
); );
} }
if (/src\/qqbot\/(account|napcat|connection)\//i.test(normalized)) { if (/src\/qqbot\/(account|napcat|connection)\//i.test(normalized)) {
addRisk( addRisk(
'medium', "medium",
file, file,
'QQBot/NapCat 登录链路改动。', "QQBot/NapCat 登录链路改动。",
'验证新增账号和更新登录都能生成有效二维码SSE 进度可见,旧 isLogin 状态不会短路。', "验证新增账号和更新登录都能生成有效二维码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( addRisk(
'medium', "medium",
file, file,
'Admin 系统管理页改动。', "Admin 系统管理页改动。",
'验证菜单权限、KtTable 展示、列表筛选和对应 API 响应结构。', "验证菜单权限、KtTable 展示、列表筛选和对应 API 响应结构。",
); );
} }
if (/Jenkinsfile|deploy\/|k8s\/|nginx.*\.conf/.test(normalized)) { if (/Jenkinsfile|deploy\/|k8s\/|nginx.*\.conf/.test(normalized)) {
addRisk('medium', file, '部署链路配置改动。', '只验证配置语法或生成产物,不主动触发远程部署。'); addRisk(
"medium",
file,
"部署链路配置改动。",
"只验证配置语法或生成产物,不主动触发远程部署。",
);
} }
if (/\.(tsx|jsx|vue)$/.test(normalized)) { if (/\.(tsx|jsx|vue)$/.test(normalized)) {
const source = readSmallFileIfExists(path.join(project.path, normalized)); const source = readSmallFileIfExists(path.join(project.path, normalized));
if (/\bfunction\s+render[A-Z0-9_]/.test(source)) { if (/\bfunction\s+render[A-Z0-9_]/.test(source)) {
addRisk( addRisk(
'medium', "medium",
file, file,
'Vue TSX render helper 使用 function 声明,容易在插槽渲染时踩 this/上下文问题。', "Vue TSX render helper 使用 function 声明,容易在插槽渲染时踩 this/上下文问题。",
'改为箭头函数,并在嵌套组件插槽中显式传 default。', "改为箭头函数,并在嵌套组件插槽中显式传 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( addRisk(
'medium', "medium",
file, file,
'TabPane 插槽疑似未显式声明 default。', "TabPane 插槽疑似未显式声明 default。",
'Vue TSX 嵌套组件插槽使用 {{ default: () => ... }} 或等价显式 default 写法。', "Vue TSX 嵌套组件插槽使用 {{ default: () => ... }} 或等价显式 default 写法。",
); );
} }
if (/\bonUpdate:[A-Za-z_$][\w$]*/.test(source)) { if (/\bonUpdate:[A-Za-z_$][\w$]*/.test(source)) {
addRisk( addRisk(
'medium', "medium",
file, file,
'Vue TSX 中存在手写 onUpdate:* 绑定。', "Vue TSX 中存在手写 onUpdate:* 绑定。",
'优先使用原生 v-model:value / v-model:activeKey 等写法,减少事件名偏差。', "优先使用原生 v-model:value / v-model:activeKey 等写法,减少事件名偏差。",
); );
} }
if (/\bdestroyOnClose\b/.test(source)) { if (/\bdestroyOnClose\b/.test(source)) {
addRisk( addRisk(
'medium', "medium",
file, file,
'Antdv Modal destroyOnClose 已废弃。', "Antdv Modal destroyOnClose 已废弃。",
'改用 destroyOnHidden或优先复用 Vben Modal/Form 封装。', "改用 destroyOnHidden或优先复用 Vben Modal/Form 封装。",
); );
} }
} }
@ -218,9 +319,9 @@ export async function scanTaskRisk(input: RiskScanInput): Promise<Record<string,
project, project,
risks, risks,
safeDefaults: [ safeDefaults: [
'不覆盖用户已有改动。', "不覆盖用户已有改动。",
'只运行必要验证。', "只运行必要验证。",
'提交前重新检查 status 和 diff。', "提交前重新检查 status 和 diff。",
], ],
}; };
} }

View File

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