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([ "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; 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; 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(), 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(); 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 { 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, 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 { 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((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> { 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((file) => ({ category: "legacy-deploy-file", file, level: "P2", message: "发现旧部署遗留文件。", project: "root", suggestion: "确认是否仍被脚本引用;无用则删除并从 Git 索引移除。", })); const rootGeneratedFindings = rootGeneratedArtifacts.map( (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, }; }