1166 lines
37 KiB
TypeScript
1166 lines
37 KiB
TypeScript
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:
|
||
"把额外说明迁入 docs,TASKS 最近记录只保留范围、关键词、验证。",
|
||
});
|
||
}
|
||
|
||
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 阶段调用统一日志提取 helper;helper 先按本次重启时间窗读取日志,再读取当前容器 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,
|
||
};
|
||
}
|