ktworkflow-mcp/src/tools/review.ts

1166 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
export const defaultReviewProjects = [
"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",
]);
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",
]);
const sensitiveTrackedFilePattern =
/(^|\/)(\.env($|\..+)|deploy\.json|secrets?\.(env|json|ya?ml)|id_rsa|[^/]+\.(pem|key))$/i;
const allowedSensitiveTrackedFilePattern = /(^|\/)\.env\.example$/i;
const frontendEnvProjectAliases = new Set<ProjectAlias>([
"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;
interface ReviewFinding {
category: string;
file?: string;
line?: number;
level: "P1" | "P2" | "P3";
message: string;
project: string;
suggestion: string;
}
interface ReviewProjectResult {
changedFiles: string[];
contentScannedFiles: string[];
credentialHints: ReviewFinding[];
debugFindings: ReviewFinding[];
findings: ReviewFinding[];
project: ResolvedProject;
repoType: RepoType;
riskScan: Record<string, unknown>;
status: string;
trackedSensitiveFiles: string[];
}
export function findTaskRecordGovernanceFindings(
lines: string[],
): ReviewFinding[] {
const findings: ReviewFinding[] = [];
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: {
fields: Set<string>;
heading: string;
line: number;
} | null = null;
const pushMissingFieldFindings = () => {
if (!currentRecord) return;
for (const field of taskRecordRequiredFields) {
if (currentRecord.fields.has(field)) continue;
findings.push({
category: "tasks-record-missing-field",
file: "TASKS.md",
line: currentRecord.line,
level: "P2",
message: `TASKS 最近记录缺少「${field}」字段。`,
project: "root",
suggestion:
"最近记录只保留范围、关键词、验证三项,详细过程迁入 docs 或历史索引。",
});
}
};
for (let index = recentStart + 1; index < recentEnd; index += 1) {
const line = lines[index].trim();
if (line.startsWith("### ")) {
pushMissingFieldFindings();
currentRecord = {
fields: new Set<string>(),
heading: line.replace(/^###\s+/, ""),
line: index + 1,
};
continue;
}
if (!currentRecord) continue;
const fieldMatch = line.match(/^-\s*([^:]+)[:]/);
if (!fieldMatch) continue;
const field = fieldMatch[1].trim();
if (taskRecordAllowedFields.has(field)) {
currentRecord.fields.add(field);
continue;
}
findings.push({
category: "tasks-record-extra-field",
file: "TASKS.md",
line: index + 1,
level: "P2",
message: `TASKS 最近记录「${currentRecord.heading}」包含额外字段「${field}」。`,
project: "root",
suggestion:
"把额外说明迁入 docsTASKS 最近记录只保留范围、关键词、验证。",
});
}
pushMissingFieldFindings();
return findings;
}
export function findNapcatImageGovernanceFindings(
lines: string[],
context: {
file: string;
project: string;
},
): ReviewFinding[] {
return lines.flatMap((line, index) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return [];
const matched = line.match(napcatImageAssignmentPattern);
if (!matched) return [];
const image = matched[1].trim();
if (!image.endsWith(":latest")) return [];
return [
{
category: "napcat-latest-image",
file: context.file,
line: index + 1,
level: "P3" as const,
message: "NapCat 运行镜像使用 latest生产排查会受到镜像漂移影响。",
project: context.project,
suggestion:
"改为明确版本 tag 或 digest并先在单账号容器观察稳定性后推广。",
},
];
});
}
export function findQqbotCommandServiceTestImportFindings(
lines: string[],
context: {
file: string;
project: string;
},
): ReviewFinding[] {
const normalized = context.file.replaceAll("\\", "/");
if (
context.project !== "Node/kt-template-online-api" ||
!normalized.startsWith("test/qqbot/") ||
!normalized.endsWith(".ts")
) {
return [];
}
return lines.flatMap((line, index) => {
if (!/from\s+['"][^'"]*qqbot-command\.service['"]/.test(line)) return [];
return [
{
category: "qqbot-command-service-heavy-test-import",
file: context.file,
line: index + 1,
level: "P2" as const,
message:
"QQBot 测试直接导入 QqbotCommandService容易拖入插件注册和 BangDream runtime 导致单测卡住。",
project: context.project,
suggestion:
"把纯策略抽到轻量函数测试,或通过 Nest TestingModule 明确 mock 掉插件 registry/runtime 依赖。",
},
];
});
}
export function findQqbotPluginSmokeImportFindings(
lines: string[],
context: {
file: string;
project: string;
},
): ReviewFinding[] {
const normalized = context.file.replaceAll("\\", "/");
if (
context.project !== "Node/kt-template-online-api" ||
!normalized.startsWith("test/modules/qqbot/") ||
!normalized.endsWith(".ts") ||
!/(plugin-(registry|controller).*\.spec\.ts|qqbot-core-module-contract\.spec\.ts)$/.test(
normalized,
)
) {
return [];
}
const source = lines.join("\n");
const pluginImportPattern =
/from\s+['"]([^'"]*modules\/qqbot\/plugins\/(bangDream|ff14Market|fflogs|repeater)\/[^'"]*(?:\.plugin|client\.service|renderer\.facade|application\.service))['"]/g;
const findings: ReviewFinding[] = [];
const seenPlugins = new Set<string>();
let match: RegExpExecArray | null;
while ((match = pluginImportPattern.exec(source))) {
const pluginName = match[2];
if (seenPlugins.has(pluginName)) continue;
seenPlugins.add(pluginName);
const escapedPluginName = pluginName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const hasMock = new RegExp(
`jest\\.mock\\s*\\(\\s*['"][^'"]*/modules/qqbot/plugins/${escapedPluginName}/`,
"m",
).test(source);
if (hasMock) continue;
const lineIndex = lines.findIndex((line) => line.includes(match?.[1] || ""));
findings.push({
category: "qqbot-plugin-smoke-heavy-import",
file: context.file,
line: lineIndex >= 0 ? lineIndex + 1 : undefined,
level: "P2" as const,
message:
"QQBot 插件 registry/controller 契约或 HTTP smoke 导入真实插件实现但未 mock容易拖入渲染、定时器或外部请求依赖。",
project: context.project,
suggestion:
"用 jest.mock mock 具体插件 service token只测试 registry/controller 的别名解析和路由包装边界。",
});
}
return findings;
}
/**
* Finds regressions that would make descriptor-backed QQBot built-in plugins disappear from production dist images.
* @param lines - Source lines from the package path policy file being reviewed.
* @param context - Project and file identity for precise review findings.
* @returns Findings when the policy no longer supports production dist roots or compiled JavaScript entries.
*/
export function findQqbotPluginPackageProductionPathFindings(
lines: string[],
context: {
file: string;
project: string;
},
): ReviewFinding[] {
const normalized = context.file.replaceAll("\\", "/");
if (
context.project !== "Node/kt-template-online-api" ||
normalized !==
"src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service.ts"
) {
return [];
}
const content = lines.join("\n");
const findings: ReviewFinding[] = [];
const hasProductionDistRoot =
content.includes("'dist'") &&
content.includes("'modules'") &&
content.includes("'qqbot'") &&
content.includes("'plugins'");
const hasCompiledEntryFallback =
content.includes("resolveCompiledEntryFile") &&
content.includes("entryFile.endsWith('.ts')") &&
content.includes("}.js`");
if (!hasProductionDistRoot) {
findings.push({
category: "qqbot-plugin-production-dist-root-missing",
file: context.file,
line: 1,
level: "P2",
message:
"QQBot 插件包路径策略缺少生产 dist 根,生产镜像只复制 dist 时内置插件会发现为空。",
project: context.project,
suggestion:
"默认受控根应在源码根不存在时扫描 dist/modules/qqbot/plugins并保留源码态优先顺序。",
});
}
if (!hasCompiledEntryFallback) {
findings.push({
category: "qqbot-plugin-compiled-entry-fallback-missing",
file: context.file,
line: 1,
level: "P2",
message:
"QQBot 插件 manifest 的 src/index.ts entry 缺少生产编译后 .js sibling fallback。",
project: context.project,
suggestion:
"entry 仍应保持源码结构声明,但生产运行时要在同一 package root 内解析到编译后的 src/index.js。",
});
}
return findings;
}
export function findQqbotStatusBoundaryFindings(
lines: string[],
context: {
file: string;
project: string;
},
): ReviewFinding[] {
const normalized = context.file.replaceAll("\\", "/");
if (
context.project !== "Node/kt-template-online-api" ||
!normalized.startsWith("src/qqbot/")
) {
return [];
}
const findings: ReviewFinding[] = [];
if (normalized === "src/qqbot/account/qqbot-account.service.ts") {
let markOnlineWindow = 0;
lines.forEach((line, index) => {
if (/async\s+markOnline\s*\(/.test(line)) {
markOnlineWindow = 20;
}
if (markOnlineWindow > 0) {
if (/\blastError\b.*=\s*null/.test(line)) {
findings.push({
category: "qqbot-mark-online-clears-login-error",
file: context.file,
line: index + 1,
level: "P2",
message:
"markOnline 默认清理 lastError会把 OneBot 重连误当成 QQ 登录恢复。",
project: context.project,
suggestion:
"让 markOnline 默认只更新 OneBot 连接字段;只有 WebUI/自动登录确认 QQ 在线时显式清理 QQ 登录态错误。",
});
}
markOnlineWindow -= 1;
}
if (/account\.connectStatus\s*===\s*['"]online['"].*return\s*['"]online['"]/.test(line)) {
findings.push({
category: "qqbot-connect-status-drives-login-status",
file: context.file,
line: index + 1,
level: "P2",
message:
"QQ 登录态不能从 connectStatus/OneBot 连接态推导为 online。",
project: context.project,
suggestion:
"qqLoginStatus 只根据 NapCat WebUI 登录状态或明确登录日志判断connectStatus 只代表 OneBot 连接。",
});
}
if (/toTime\(account\.lastHeartbeatAt\)/.test(line)) {
findings.push({
category: "qqbot-heartbeat-bypasses-login-check",
file: context.file,
line: index + 1,
level: "P2",
message:
"OneBot heartbeat 不能绕过 NapCat QQ 登录态检查。",
project: context.project,
suggestion:
"心跳只证明容器/OneBot 通信活着;陈旧登录态仍要通过 NapCat WebUI/日志刷新。",
});
}
});
}
if (normalized === "src/qqbot/connection/qqbot-reverse-ws.service.ts") {
lines.forEach((line, index) => {
if (!/ws\.on\(['"]close['"]/.test(line)) return;
const body = lines.slice(index, index + 8).join("\n");
if (
body.includes("this.connections.delete(key)") &&
!body.includes("isCurrentConnection")
) {
findings.push({
category: "qqbot-stale-ws-close-marks-offline",
file: context.file,
line: index + 1,
level: "P2",
message:
"WS close 处理没有校验当前连接身份,陈旧连接可能误删新连接并标记 OneBot offline。",
project: context.project,
suggestion:
"close/error/timeout 清理前先确认 map 中的当前连接仍是触发事件的 ws。",
});
}
});
}
return findings;
}
export function findQqbotNapcatCaptchaFlowFindings(
lines: string[],
context: {
file: string;
project: string;
},
): ReviewFinding[] {
const normalized = context.file.replaceAll("\\", "/");
if (
context.project === "Node/kt-template-online-api" &&
normalized === "src/qqbot/napcat/qqbot-napcat-container.service.ts"
) {
const detectLine = lines.findIndex((line) =>
/async\s+detectRuntimeCaptchaUrl\s*\(/.test(line),
);
const detectBody =
detectLine >= 0 ? lines.slice(detectLine, detectLine + 45).join("\n") : "";
const findings: ReviewFinding[] = [];
if (
detectBody &&
(detectBody.includes("this.getRuntimeCheckTimeoutMs()") ||
!detectBody.includes("this.getCaptchaLogReadTimeoutMs()"))
) {
findings.push({
category: "qqbot-napcat-captcha-log-timeout-too-short",
file: context.file,
line: detectLine + 1,
level: "P2",
message:
"NapCat 验证码日志读取复用了 5 秒运行态快检超时API Pod 到 NAS 的 SSH 读日志可能被提前杀掉。",
project: context.project,
suggestion:
"detectRuntimeCaptchaUrl 使用独立的验证码日志读取超时,默认不要低于 15 秒,避免 proofWaterUrl 已存在但被 SSH 超时吞掉。",
});
}
return findings;
}
if (
context.project !== "Node/kt-template-online-api" ||
normalized !== "src/qqbot/account/qqbot-napcat-login.service.ts"
) {
return [];
}
const content = lines.join("\n");
const findings: ReviewFinding[] = [];
const waitLine = lines.findIndex((line) =>
/private\s+async\s+waitForPasswordLoginStatus\s*\(/.test(line),
);
const resolveLine = lines.findIndex((line) =>
/private\s+async\s+resolvePasswordCaptchaUrl\s*\(/.test(line),
);
const helperLine = lines.findIndex((line) =>
/private\s+async\s+detectPasswordCaptchaUrl\s*\(/.test(line),
);
const waitCaptchaLine = lines.findIndex((line) =>
/private\s+async\s+waitForPasswordCaptchaUrl\s*\(/.test(line),
);
const statusLine = lines.findIndex((line) =>
/async\s+status\s*\(\s*sessionId/.test(line),
);
const passwordReloginLine = lines.findIndex((line) =>
/private\s+async\s+tryPasswordRelogin\s*\(/.test(line),
);
const waitBody =
waitLine >= 0 ? lines.slice(waitLine, waitLine + 90).join("\n") : "";
const statusBody =
statusLine >= 0 ? lines.slice(statusLine, statusLine + 90).join("\n") : "";
const passwordReloginBody =
passwordReloginLine >= 0
? lines.slice(passwordReloginLine, passwordReloginLine + 140).join("\n")
: "";
if (
content.includes("waitForPasswordLoginStatus") &&
(!waitBody.includes("isNapcatCaptchaRequiredMessage(errorMessage)") ||
!waitBody.includes("detectPasswordCaptchaUrl("))
) {
findings.push({
category: "qqbot-napcat-captcha-error-throws-before-pending",
file: context.file,
line: waitLine >= 0 ? waitLine + 1 : undefined,
level: "P2",
message:
"NapCat 密码登录验证码错误没有在清理容器前转换为 pending 状态。",
project: context.project,
suggestion:
"捕获 CheckLoginStatus 的验证码错误,转成 loginError 状态,再从当前密码登录容器日志提取 proofWaterUrl。",
});
}
const resolveBody =
resolveLine >= 0 ? lines.slice(resolveLine, resolveLine + 60).join("\n") : "";
const helperBody =
helperLine >= 0 ? lines.slice(helperLine, helperLine + 30).join("\n") : "";
const waitCaptchaBody =
waitCaptchaLine >= 0
? lines.slice(waitCaptchaLine, waitCaptchaLine + 40).join("\n")
: "";
const runtimeLogReads = (
helperBody.match(/detectRuntimeCaptchaUrl\s*\(/g) || []
).length;
const boundedRuntimeLogRead = /detectPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,\s*false\s*,?\s*\)/.test(
resolveBody,
);
const captchaTailFallbackRead = /detectPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,\s*true\s*,?\s*\)/.test(
resolveBody,
);
const captchaWaitFallbackRead = /detectPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,\s*true\s*,?\s*\)/.test(
waitCaptchaBody,
);
if (
resolveBody &&
(!resolveBody.includes("detectPasswordCaptchaUrl(") ||
runtimeLogReads < 2 ||
!boundedRuntimeLogRead ||
(!captchaTailFallbackRead && !captchaWaitFallbackRead))
) {
findings.push({
category: "qqbot-napcat-captcha-log-single-read",
file: context.file,
line: resolveLine + 1,
level: "P2",
message:
"验证码 URL 日志只读一次,时间窗偏差时可能在清理前拿不到 proofWaterUrl。",
project: context.project,
suggestion:
"resolve 阶段调用统一日志提取 helperhelper 先按本次重启时间窗读取日志,再读取当前容器 tail 兜底。",
});
}
const resolveUsesDelayedCaptchaWait = /waitForPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,?\s*\)/.test(
resolveBody,
);
const delayedCaptchaWaitHasLoop =
/\bfor\s*\(|\bwhile\s*\(/.test(waitCaptchaBody) ||
/const\s+attempts\s*=/.test(waitCaptchaBody);
const delayedCaptchaWaitHasSleep =
waitCaptchaBody.includes("this.toolsService.sleep(") &&
waitCaptchaBody.includes("this.getLoginPollIntervalMs()");
if (
resolveBody &&
(!resolveUsesDelayedCaptchaWait ||
!delayedCaptchaWaitHasLoop ||
!captchaWaitFallbackRead ||
!delayedCaptchaWaitHasSleep)
) {
findings.push({
category: "qqbot-napcat-captcha-status-before-log-url",
file: context.file,
line: waitCaptchaLine >= 0 ? waitCaptchaLine + 1 : resolveLine + 1,
level: "P2",
message:
"NapCat 验证码状态可能先于 Docker 日志 URL 到达,直接失败会在清理容器前丢失 proofWaterUrl。",
project: context.project,
suggestion:
"验证码状态确认后,在失败清理运行态密码前用短窗口轮询当前容器日志,拿到 proofWaterUrl 后保持 captcha pending。",
});
}
if (
statusBody.includes("session.captchaUrl") &&
!statusBody.includes("isPasswordCaptchaStillRequired(status)")
) {
findings.push({
category: "qqbot-napcat-captcha-status-clears-pending",
file: context.file,
line: statusLine + 1,
level: "P2",
message:
"NapCat 验证码 pending 后,状态轮询可能把仍需验证码的无 URL 状态误判为失败并清掉 captchaUrl。",
project: context.project,
suggestion:
"status() 在 session 已有 captchaUrl 且当前状态仍提示需要验证码/安全验证时,应继续 keepPasswordCaptchaPending而不是 failCaptchaLogin。",
});
}
const hasLegacyDeviceVerifyPending =
content.includes("keepDeviceVerifyPending") &&
content.includes("deviceVerifyUrl");
const hasNapcatNewDeviceApiFlow =
content.includes("GetNewDeviceQRCode") &&
content.includes("PollNewDeviceQR") &&
content.includes("NewDeviceLogin") &&
content.includes("deviceVerifyUrl") &&
content.includes("newDeviceQrcode") &&
content.includes("newDeviceStatus") &&
content.includes("session.captchaUrl = undefined");
const hasNapcatNewDeviceWrapperFlow =
content.includes("startNewDeviceVerification") &&
content.includes("pollNewDeviceVerification") &&
content.includes("completePasswordLoginAfterChallenge") &&
content.includes("deviceVerifyUrl") &&
content.includes("newDeviceQrcode") &&
content.includes("newDeviceStatus") &&
content.includes("session.captchaUrl = undefined");
if (
content.includes("needNewDevice") &&
!hasLegacyDeviceVerifyPending &&
!hasNapcatNewDeviceApiFlow &&
!hasNapcatNewDeviceWrapperFlow
) {
findings.push({
category: "qqbot-napcat-new-device-drops-pending",
file: context.file,
line:
lines.findIndex((line) => line.includes("needNewDevice")) + 1 ||
undefined,
level: "P2",
message:
"NapCat 验证码通过后仍可能要求新设备验证,直接提示或失败会丢掉可继续的 jumpUrl。",
project: context.project,
suggestion:
"CaptchaLogin 返回 needNewDevice/jumpUrl 时,应保持同一 scan session pending返回 deviceVerifyUrl清掉旧 captchaUrl并继续轮询同一容器。",
});
}
const logAnchorMatch = passwordReloginBody.match(
/const\s+([A-Za-z0-9_]*LogSinceMs)\s*=\s*Date\.now\(\)[\s\S]*?ensureRuntimeLoginEnv/,
);
const logAnchorName = logAnchorMatch?.[1];
const waitUsesLogAnchor =
!!logAnchorName &&
new RegExp(
`waitForPasswordLoginStatus\\s*\\(\\s*container\\s*,\\s*${logAnchorName}\\s*,?\\s*\\)`,
).test(passwordReloginBody);
const resolveUsesLogAnchor =
!!logAnchorName &&
new RegExp(
`resolvePasswordCaptchaUrl\\s*\\(\\s*container\\s*,\\s*loginStatus\\s*,\\s*${logAnchorName}\\s*,?\\s*\\)`,
).test(passwordReloginBody);
if (
passwordReloginBody &&
(!logAnchorName || !waitUsesLogAnchor || !resolveUsesLogAnchor)
) {
findings.push({
category: "qqbot-napcat-captcha-log-window-late",
file: context.file,
line: passwordReloginLine + 1,
level: "P2",
message:
"NapCat 密码验证码日志窗口可能锚在容器重启之后,导致 proofWaterUrl 被漏抓。",
project: context.project,
suggestion:
"在准备 NAPCAT_QUICK_PASSWORD 环境前记录日志窗口起点,并把同一个时间戳传给 waitForPasswordLoginStatus 和 resolvePasswordCaptchaUrl。",
});
}
return findings;
}
async function listGitTrackedFiles(projectPath: string): Promise<string[]> {
const tracked = await tryExecFile("git", ["ls-files"], projectPath);
if (!tracked.ok) return [];
return tracked.stdout
.split(/\r?\n/)
.map((file) => file.trim())
.filter(Boolean)
.map((file) => file.replaceAll("\\", "/"));
}
function shouldReadReviewFile(file: string): boolean {
const normalized = file.replaceAll("\\", "/");
return !reviewSkipFilePattern.test(normalized);
}
function readProjectFileLines(projectPath: string, file: string): string[] {
const filePath = path.join(projectPath, file);
if (!existsSync(filePath)) return [];
try {
const stats = statSync(filePath);
if (!stats.isFile() || stats.size > 1024 * 1024) return [];
return readFileSync(filePath, "utf8").split(/\r?\n/);
} catch {
return [];
}
}
function isBenignCredentialValue(value: string): boolean {
const normalized = value
.trim()
.replace(/^["']|["']$/g, "")
.toLowerCase();
if (!normalized) return true;
if (normalized.startsWith("$") || normalized.startsWith("${")) return true;
if (normalized.startsWith("<") || normalized.includes("example")) return true;
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",
].includes(normalized);
}
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 &&
/[\u3400-\u9fff]/.test(value) &&
!/[a-z0-9_$./\\:=]/i.test(value)
);
}
function isRuntimeDebugAllowed(
projectAlias: ProjectAlias | null,
file: string,
): boolean {
const normalized = file.replaceAll("\\", "/");
if (/^(README|skills\/|.*\.md$)/.test(normalized)) return true;
if (
projectAlias === "admin" &&
normalized.startsWith("internal/vite-config/")
) {
return true;
}
if (
projectAlias === "mcp" &&
/^(scripts\/|src\/server\.ts$|src\/selfTest\.ts$)/.test(normalized)
) {
return true;
}
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 trimmed = line.trimStart();
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 {
if (allowedSensitiveTrackedFilePattern.test(file)) return true;
if (projectAlias && frontendEnvProjectAliases.has(projectAlias)) {
return /(^|\/)\.env($|\..+)/i.test(file);
}
return false;
}
function collectProjectContentFindings(
project: ResolvedProject,
trackedFiles: string[],
includeContentScan: boolean,
maxFindings: number,
): {
credentialHints: ReviewFinding[];
debugFindings: ReviewFinding[];
findings: ReviewFinding[];
} {
const findings: ReviewFinding[] = [];
const credentialHints: ReviewFinding[] = [];
const debugFindings: ReviewFinding[] = [];
for (const file of trackedFiles.filter(shouldReadReviewFile)) {
const lines = readProjectFileLines(project.path, file);
if (lines.length === 0) continue;
lines.forEach((line, index) => {
if (/^(<<<<<<<|=======|>>>>>>>) /.test(line)) {
findings.push({
category: "conflict-marker",
file,
line: index + 1,
level: "P1",
message: "发现 Git 冲突标记。",
project: project.relativePath,
suggestion: "先解决冲突标记,再继续构建或提交。",
});
}
if (
!isRuntimeDebugAllowed(project.alias, file) &&
/\bdebugger;|console\.log\(/.test(line)
) {
debugFindings.push({
category: "runtime-debug",
file,
line: index + 1,
level: /\bdebugger;/.test(line) ? "P2" : "P3",
message: "运行时代码中存在调试输出。",
project: project.relativePath,
suggestion: "页面或服务运行时代码不要保留 console.log/debugger。",
});
}
if (includeContentScan) {
if (
/\.(tsx|jsx|vue)$/.test(file) &&
/\bfunction\s+render[A-Z0-9_]/.test(line)
) {
findings.push({
category: "vue-tsx-render-helper",
file,
line: index + 1,
level: "P2",
message: "Vue TSX render helper 使用 function 声明。",
project: project.relativePath,
suggestion: "改为箭头函数,并在嵌套组件插槽中显式传 default。",
});
}
const credentialMatch = line.match(credentialAssignmentPattern);
if (
credentialMatch &&
!allowedSensitiveTrackedFilePattern.test(file) &&
!isCredentialHintAllowed(file, line) &&
!isBenignCredentialValue(credentialMatch[3])
) {
credentialHints.push({
category: "credential-literal",
file,
line: index + 1,
level: "P2",
message: `疑似 ${credentialMatch[1]} 字面量赋值。`,
project: project.relativePath,
suggestion:
"确认是否为真实凭据;真实值应移入本地 env 或远程 Secret。",
});
}
}
});
if (includeContentScan) {
findings.push(
...findNapcatImageGovernanceFindings(lines, {
file,
project: project.relativePath,
}),
...findQqbotCommandServiceTestImportFindings(lines, {
file,
project: project.relativePath,
}),
...findQqbotPluginSmokeImportFindings(lines, {
file,
project: project.relativePath,
}),
...findQqbotPluginPackageProductionPathFindings(lines, {
file,
project: project.relativePath,
}),
...findQqbotStatusBoundaryFindings(lines, {
file,
project: project.relativePath,
}),
...findQqbotNapcatCaptchaFlowFindings(lines, {
file,
project: project.relativePath,
}),
);
}
if (
findings.length + credentialHints.length + debugFindings.length >=
maxFindings * 3
) {
break;
}
}
return {
credentialHints: credentialHints.slice(0, maxFindings),
debugFindings: debugFindings.slice(0, maxFindings),
findings: findings.slice(0, maxFindings),
};
}
function findWorkspaceFilesByName(
names: Set<string>,
maxResults: number,
): string[] {
const results: string[] = [];
const walk = (currentPath: string) => {
if (results.length >= maxResults) return;
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
if (results.length >= maxResults) return;
if (entry.isDirectory()) {
if (reviewSkipDirectoryNames.has(entry.name)) continue;
walk(path.join(currentPath, entry.name));
continue;
}
if (entry.isFile() && names.has(entry.name)) {
results.push(
toPosix(
path.relative(workspaceRoot, path.join(currentPath, entry.name)),
),
);
}
}
};
walk(workspaceRoot);
return results;
}
function findRootGeneratedArtifacts(): string[] {
if (!existsSync(workspaceRoot)) return [];
return readdirSync(workspaceRoot, { withFileTypes: true })
.filter((entry) => rootGeneratedArtifactNames.has(entry.name))
.map((entry) => entry.name);
}
async function reviewProject(
projectKey: string,
input: Required<
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: "" };
const changedFiles = status.ok ? parseGitStatusFiles(status.stdout) : [];
const trackedFiles =
repoType === "git" ? await listGitTrackedFiles(project.path) : [];
const trackedFileSet = new Set(trackedFiles);
const contentScannedFiles =
input.contentScanMode === "all"
? trackedFiles
: changedFiles
.map((file) => file.replaceAll("\\", "/"))
.filter((file) => shouldReadReviewFile(file))
.filter(
(file) =>
trackedFileSet.has(file) ||
existsSync(path.join(project.path, file)),
);
const trackedSensitiveFiles = trackedFiles.filter(
(file) =>
sensitiveTrackedFilePattern.test(file) &&
!isAllowedSensitiveTrackedFile(project.alias, file),
);
const contentFindings = collectProjectContentFindings(
project,
contentScannedFiles,
input.includeContentScan,
input.maxFindingsPerProject,
);
const trackedSensitiveFindings = trackedSensitiveFiles
.slice(0, input.maxFindingsPerProject)
.map<ReviewFinding>((file) => ({
category: "tracked-sensitive-file",
file,
level: "P1",
message: "仓库仍跟踪真实环境或部署凭据类文件。",
project: project.relativePath,
suggestion:
"使用 git rm --cached 移出索引,并提交 example 文件或文档说明。",
}));
return {
changedFiles,
contentScannedFiles,
credentialHints: contentFindings.credentialHints,
debugFindings: contentFindings.debugFindings,
findings: [...trackedSensitiveFindings, ...contentFindings.findings],
project,
repoType,
riskScan: await scanTaskRisk({
changedFiles,
project: projectKey,
}),
status: status.ok ? status.stdout : status.stderr,
trackedSensitiveFiles,
};
}
export async function buildGlobalCodeReview(
input: GlobalCodeReviewInput = {},
): Promise<Record<string, unknown>> {
const projects =
(input.projects?.length ?? 0) > 0
? input.projects!
: [...defaultReviewProjects];
const includeContentScan = input.includeContentScan ?? true;
const contentScanMode = input.contentScanMode ?? "changed";
const maxFindingsPerProject = Math.max(
1,
Math.min(input.maxFindingsPerProject ?? 20, 200),
);
const projectResults = await Promise.all(
projects.map((project) =>
reviewProject(project, {
contentScanMode,
includeContentScan,
maxFindingsPerProject,
}),
),
);
const rootLegacyFiles =
input.includeRootScan === false
? []
: findWorkspaceFilesByName(new Set(["deploy.js", "deploy.json"]), 50);
const rootGeneratedArtifacts =
input.includeRootScan === false ? [] : findRootGeneratedArtifacts();
const taskRecordFindings =
input.includeRootScan === false
? []
: 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",
file,
level: "P2",
message: "发现旧部署遗留文件。",
project: "root",
suggestion: "确认是否仍被脚本引用;无用则删除并从 Git 索引移除。",
}));
const rootGeneratedFindings = rootGeneratedArtifacts.map<ReviewFinding>(
(file) => ({
category: "root-generated-artifact",
file,
level: "P2",
message: "根目录存在生成态临时产物。",
project: "root",
suggestion:
"统一迁入 .kt-workspace 对应子目录,并更新生成脚本或测试流程。",
}),
);
const allFindings = [
...findings,
...rootFindings,
...rootGeneratedFindings,
...taskRecordFindings,
];
return {
findings: allFindings,
generatedAt: new Date().toISOString(),
notes: [
"本工具只读扫描,不会修改文件、删除文件或提交代码。",
"P1 优先处理:冲突标记、被跟踪的真实 env/部署凭据文件。",
"默认 contentScanMode=changed只对当前变更文件做 P2/P3 内容扫描,并放过短中文显示标签,避免历史误报污染上下文;需要全仓库深扫时传 all。",
"P2 需要人工确认:疑似凭据字面量、旧部署文件、调试断点。",
"P3 为清洁度问题:运行时 console.log 等。",
],
projects: projectResults.map((item) => ({
changedFiles: item.changedFiles,
contentScannedFileCount: item.contentScannedFiles.length,
credentialHintCount: item.credentialHints.length,
debugFindingCount: item.debugFindings.length,
findingCount: item.findings.length,
project: item.project,
repoType: item.repoType,
riskScan: item.riskScan,
status: item.status,
trackedSensitiveFiles: item.trackedSensitiveFiles,
})),
summary: {
changedProjectCount: projectResults.filter(
(item) => item.changedFiles.length > 0,
).length,
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,
reviewedProjects: projectResults.length,
rootGeneratedArtifacts,
rootLegacyFiles,
},
workspaceRoot,
};
}