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 { findContextDependentVueTsxRenderHelpers } from "../core/vueTsxReview.js"; import { resolveProject, toPosix, workspaceRoot } from "../core/workspace.js"; import { scanTaskRisk } from "./envRisk.js"; export const defaultReviewProjects = [ "api", "admin", "blog", "web", "playground", "mcp", "napcat", "networkAgent", "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", "_exports", "codex-test-logs", "test-artifacts", ]); const workspaceAssetBlockedFilePattern = /(^|\/)(\.env($|\..+)|.*\.(7z|dump|gz|rar|sql|storageState[^/]*|tar|tgz|zip)|id_rsa|[^/]+\.(key|pem|pfx|p12))$/i; const workspaceAssetMaxFileBytes = 2 * 1024 * 1024; 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; const taskRequiredSecondLevelSections = [ "当前上下文", "维护规则", "最近记录", "历史索引", ] as const; const taskFileMaxBytes = 14 * 1024; const taskHistoryItemMaxChars = 180; const taskRecordFieldMaxChars = 360; const taskRecentRecordLimit = 3; const blogLive2DDeobfuscationLedgerFile = "docs/blog-live2d-cubism2-minjs-deobfuscation-ledger.md"; const blogLive2DDeobfuscationLedgerMaxBytes = 128 * 1024; const blogLive2DDeobfuscationLedgerRowMaxChars = 520; const reviewContentMaxFileBytes = 1024 * 1024; 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[]; } /** * Checks the root task index for compact recent-record governance. * @param lines - Current `TASKS.md` content split by line, used to inspect the `## 最近记录` section without rereading files. * @returns Review findings for missing fields, extra fields, or too many detailed recent task records. */ export function findTaskRecordGovernanceFindings( lines: string[], ): ReviewFinding[] { const findings: ReviewFinding[] = []; findings.push(...findTaskFileShapeFindings(lines)); const recentStart = lines.findIndex((line) => line.trim() === "## 最近记录"); const historyStart = lines.findIndex((line) => line.trim() === "## 历史索引"); if (historyStart >= 0) { findings.push(...findTaskHistoryIndexFindings(lines, historyStart)); } 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; const recordHeadings = lines .slice(recentStart + 1, recentEnd) .map((line, offset) => ({ line, lineNumber: recentStart + 2 + offset })) .filter((item) => item.line.trim().startsWith("### ")); if (recordHeadings.length > taskRecentRecordLimit) { findings.push({ category: "tasks-recent-record-limit", file: "TASKS.md", line: recordHeadings[taskRecentRecordLimit]?.lineNumber, level: "P2", message: `TASKS 最近记录只允许保留 ${taskRecentRecordLimit} 条详细记录,当前为 ${recordHeadings.length} 条。`, project: "root", suggestion: "保留最新 3 条详细记录,其余迁入「历史索引」的关键词条目。", }); } 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; if (!line) continue; const fieldMatch = line.match(/^-\s*([^::]+)[::]/); if (!fieldMatch) { findings.push({ category: "tasks-record-freeform-line", file: "TASKS.md", line: index + 1, level: "P2", message: `TASKS 最近记录「${currentRecord.heading}」包含非三字段正文。`, project: "root", suggestion: "最近记录只允许三条 bullet:范围、关键词、验证;补充段、子列表、表格、代码块全部迁入 docs 或历史索引。", }); continue; } const field = fieldMatch[1].trim(); if (taskRecordAllowedFields.has(field)) { currentRecord.fields.add(field); if (line.length > taskRecordFieldMaxChars) { findings.push({ category: "tasks-record-field-too-long", file: "TASKS.md", line: index + 1, level: "P2", message: `TASKS 最近记录字段「${field}」过长,当前 ${line.length} 字,限制 ${taskRecordFieldMaxChars} 字。`, project: "root", suggestion: "把长证据、长复现和长命令移入 docs 或历史索引短关键词,最近记录只保留摘要。", }); } 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; } /** * Checks the whole task index shape before section-specific recent-record checks. * @param lines - Current `TASKS.md` content split by line. * @returns Review findings for total size or required second-level section drift. */ function findTaskFileShapeFindings(lines: string[]): ReviewFinding[] { const findings: ReviewFinding[] = []; const byteLength = Buffer.byteLength(lines.join("\n"), "utf8"); if (byteLength > taskFileMaxBytes) { findings.push({ category: "tasks-file-size-limit", file: "TASKS.md", level: "P2", message: `TASKS.md 只能作为压缩索引,当前 ${byteLength} bytes,限制 ${taskFileMaxBytes} bytes。`, project: "root", suggestion: "删除流水账、长验证、截图清单和完整命令;必要细节放入 docs,TASKS 只保留索引。", }); } const sectionHeadings = lines .map((line, index) => ({ line, lineNumber: index + 1 })) .filter((item) => /^##\s+/.test(item.line.trim())); const actualSections = sectionHeadings.map((item) => item.line.trim().replace(/^##\s+/, ""), ); const expectedSections = [...taskRequiredSecondLevelSections]; const hasExactSectionOrder = actualSections.length === expectedSections.length && expectedSections.every((section, index) => actualSections[index] === section); if (!hasExactSectionOrder) { findings.push({ category: "tasks-section-order", file: "TASKS.md", line: sectionHeadings[0]?.lineNumber ?? 1, level: "P2", message: `TASKS.md 二级章节必须固定为 ${expectedSections.join(" -> ")}。`, project: "root", suggestion: "不要新增临时章节、补充章节或任务分组;把额外内容压缩进最近记录三字段或历史索引分类 bullet。", }); } return findings; } /** * Checks the compact history index so old work cannot become another long-form log sink. * @param lines - Current `TASKS.md` content split by line. * @param historyStart - Zero-based line index of `## 历史索引`. * @returns Review findings for long or free-form history index content. */ function findTaskHistoryIndexFindings( lines: string[], historyStart: number, ): ReviewFinding[] { const findings: ReviewFinding[] = []; for (let index = historyStart + 1; index < lines.length; index += 1) { const line = lines[index].trim(); if (!line) continue; if (line.startsWith("### ")) continue; if (!line.startsWith("- ")) { findings.push({ category: "tasks-history-freeform-line", file: "TASKS.md", line: index + 1, level: "P2", message: "TASKS 历史索引只能使用分类标题和单行关键词 bullet。", project: "root", suggestion: "删除段落、代码块、表格和补充说明;历史索引只保留可检索关键词。", }); continue; } if (line.length <= taskHistoryItemMaxChars) continue; findings.push({ category: "tasks-history-item-too-long", file: "TASKS.md", line: index + 1, level: "P2", message: `TASKS 历史索引条目过长,当前 ${line.length} 字,限制 ${taskHistoryItemMaxChars} 字。`, project: "root", suggestion: "把旧任务压缩成日期、主题和关键词;长背景移入 docs 或删除。", }); } return findings; } /** * Checks the Live2D Cubism2 deobfuscation ledger remains a compact, contiguous slice index instead of regrowing per-slice execution logs. * @param lines Current ledger content split by line; rows beginning with `S |` are treated as the durable slice index. * @param actualByteLength Real file size when the caller read a file; omitted direct-call fixtures use a conservative CRLF estimate. * @returns Review findings for size overflow, verbose slice sections, pasted execution logs, overlong rows, or a non-contiguous slice sequence. */ export function findBlogLive2DDeobfuscationLedgerFindings( lines: string[], actualByteLength?: number, ): ReviewFinding[] { const findings: ReviewFinding[] = []; const lfByteLength = Buffer.byteLength(lines.join("\n"), "utf8"); const byteLength = actualByteLength ?? lfByteLength + Math.max(0, lines.length - 1); if (byteLength > blogLive2DDeobfuscationLedgerMaxBytes) { findings.push(createBlogLive2DLedgerSizeFinding(byteLength)); } const sliceIds: number[] = []; lines.forEach((line, index) => { const trimmed = line.trim(); const sliceRowMatch = trimmed.match(/^S(\d+)\s*\|/u); if (sliceRowMatch) { sliceIds.push(Number(sliceRowMatch[1])); if (line.length > blogLive2DDeobfuscationLedgerRowMaxChars) { findings.push({ category: "blog-live2d-ledger-row-too-long", file: blogLive2DDeobfuscationLedgerFile, line: index + 1, level: "P2", message: `Blog Live2D 切片索引行过长,当前 ${line.length} 字,限制 ${blogLive2DDeobfuscationLedgerRowMaxChars} 字。`, project: "root", suggestion: "每个切片只保留动作、恢复结果和证据文件的单句摘要,完整细节写入对应 renames-*.json。", }); } } if ( /^###\s+(?:(?:\*\*|__|`)\s*)?S\d+(?:\s*(?:\*\*|__|`))?(?:\s|[::—–-]|$)/u.test( trimmed, ) ) { findings.push({ category: "blog-live2d-ledger-detailed-slice-section", file: blogLive2DDeobfuscationLedgerFile, line: index + 1, level: "P2", message: "Blog Live2D 反混淆清单重新出现逐切片详细章节。", project: "root", suggestion: "把该切片压成 `S | 主题 | 动作/改动 | 恢复结果 | 证据` 单行,避免重复保存执行过程。", }); } if ( /^[-*+]\s+(?:\[[ xX]\]\s+)?(?:(?:\*\*|__|`)\s*)?(?:红灯|绿灯|测试(?:结果|输出)?|test|类型检查|type[- ]?check|验证(?:结果|输出)?|复审|review|vitest|子代理|命令(?:输出)?|command|执行(?:结果|输出)?)(?:\s*(?:\*\*|__|`))?[::]/iu.test( trimmed, ) ) { findings.push({ category: "blog-live2d-ledger-execution-log", file: blogLive2DDeobfuscationLedgerFile, line: index + 1, level: "P2", message: "Blog Live2D 反混淆清单包含执行或验证流水。", project: "root", suggestion: "ledger 只保存稳定结论和验证入口;具体红绿输出、命令与审查过程由测试、工具和临时证据承载。", }); } }); const expectedSliceIds = Array.from( { length: sliceIds.length }, (_, index) => index, ); if ( sliceIds.length === 0 || sliceIds.some((sliceId, index) => sliceId !== expectedSliceIds[index]) ) { findings.push({ category: "blog-live2d-ledger-slice-sequence", file: blogLive2DDeobfuscationLedgerFile, level: "P2", message: "Blog Live2D 反混淆切片索引必须从 S0 开始、按编号连续且每项只出现一次。", project: "root", suggestion: "按数字排序切片行并补齐缺失编号;不要用新详细章节或重复编号代替单行索引。", }); } return findings; } /** * Builds the stable size-limit finding shared by line-level and file-level ledger checks. * @param byteLength Actual or conservatively estimated ledger size in UTF-8 bytes. * @returns One P2 finding that routes verbose details out of the compact ledger. */ function createBlogLive2DLedgerSizeFinding( byteLength: number, ): ReviewFinding { return { category: "blog-live2d-ledger-size-limit", file: blogLive2DDeobfuscationLedgerFile, level: "P2", message: `Blog Live2D 反混淆清单当前 ${byteLength} bytes,限制 ${blogLive2DDeobfuscationLedgerMaxBytes} bytes。`, project: "root", suggestion: "保留单行切片索引;逐条 sourceAnchors 写入 renames-*.json,命令输出、红绿过程和子代理流水留在测试证据。", }; } /** * Reads and audits the compact Live2D ledger at a workspace root without losing an oversized-file diagnosis to the generic content-scan cap. * @param projectPath Workspace root that owns the `docs/` ledger path. * @returns Ledger findings based on the real file byte size; files above the read cap return only the actionable size finding. */ function findBlogLive2DDeobfuscationLedgerFileFindings( projectPath: string, ): ReviewFinding[] { const filePath = path.join(projectPath, blogLive2DDeobfuscationLedgerFile); if (!existsSync(filePath)) { return findBlogLive2DDeobfuscationLedgerFindings([]); } try { const stats = statSync(filePath); if (!stats.isFile()) { return findBlogLive2DDeobfuscationLedgerFindings([]); } if (stats.size > reviewContentMaxFileBytes) { return [createBlogLive2DLedgerSizeFinding(stats.size)]; } return findBlogLive2DDeobfuscationLedgerFindings( readFileSync(filePath, "utf8").split(/\r?\n/), stats.size, ); } catch { return findBlogLive2DDeobfuscationLedgerFindings([]); } } 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,并先在单账号容器观察稳定性后推广。", }, ]; }); } const blogLive2DBaseDataIdDefaultConsumerFiles = new Set([ "src/components/blog/live2d/vendor/cubism2Core/baseData.ts", "src/components/blog/live2d/vendor/cubism2Core/drawData.ts", "src/components/blog/live2d/vendor/cubism2Core/modelContext.ts", ]); /** * Finds restored Blog Live2D Cubism2 consumers that regress from the semantic default-base sentinel entry to a residual min.js alias. * @param lines - Source lines from the reviewed Cubism2 compatibility module. * @param context - Project and file identity; this transitional check targets consumers while the final zero-residual audit owns definition-side removal. * @returns Review findings for direct `BaseDataID._$2o()` calls in extracted consumer modules. */ export function findBlogLive2DBaseDataIdConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || !blogLive2DBaseDataIdDefaultConsumerFiles.has(normalized) ) { return []; } return lines.flatMap((line, index) => { if (!/\b(?:options\.)?BaseDataID\._\$2o\s*\(/.test(line)) return []; return [ { category: "blog-live2d-basedataid-default-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D 已拆 Cubism2 模块不应直接消费 BaseDataID._$2o 默认 base sentinel。", project: context.project, suggestion: "改用 BaseDataID.getDefaultBaseDataID();_$2o 定义侧残留只表示迁移未完成,收敛该依赖簇时必须删除。", }, ]; }); } const blogLive2DRuntimeConstantsConsumerFiles = new Set([ "src/components/blog/live2d/vendor/cubism2Core/drawData.ts", "src/components/blog/live2d/vendor/cubism2Core/modelContext.ts", "src/components/blog/live2d/vendor/cubism2Core/paramBinding.ts", ]); const blogLive2DRuntimeConstantsAliasPattern = /\bCubism2RuntimeConstants\._\$(?:do|Ms|Qs|Ls|i2|No|J|Qb|1r)\b/; /** * Finds restored Blog Live2D Cubism2 consumers that regress from semantic runtime constants to residual min.js aliases. * @param lines - Source lines from the reviewed Cubism2 compatibility module. * @param context - Project and file identity; this transitional check targets consumers while the final zero-residual audit owns definition-side removal. * @returns Review findings for direct `Cubism2RuntimeConstants._$...` calls in extracted consumer modules. */ export function findBlogLive2DRuntimeConstantsConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || !blogLive2DRuntimeConstantsConsumerFiles.has(normalized) ) { return []; } return lines.flatMap((line, index) => { if (!blogLive2DRuntimeConstantsAliasPattern.test(line)) return []; return [ { category: "blog-live2d-runtime-constants-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D 已拆 Cubism2 模块不应直接消费 Cubism2RuntimeConstants._$... 旧 constants alias。", project: context.project, suggestion: "改用 activeCoordinateMode、POINT_TUPLE_SIZE、PARAM_VALUE_EPSILON 等语义常量;定义侧短名残留须在该依赖簇收敛时删除。", }, ]; }); } const blogLive2DTargetBaseTransformConsumerFiles = new Set([ "src/components/blog/live2d/vendor/cubism2Core/gridBaseData.ts", "src/components/blog/live2d/vendor/cubism2Core/transformBaseData.ts", ]); /** * Finds restored Blog Live2D Cubism2 target-base consumers that regress to a residual point-transform alias. * @param lines - Source lines from the reviewed Cubism2 compatibility module. * @param context - Project and file identity; this transitional check targets consumers while the final zero-residual audit owns definition-side removal. * @returns Review findings for direct `targetBaseData._$nb(...)` calls in extracted consumer modules. */ export function findBlogLive2DTargetBaseTransformConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || !blogLive2DTargetBaseTransformConsumerFiles.has(normalized) ) { return []; } return lines.flatMap((line, index) => { if (!/\btargetBaseData\._\$nb\s*\(/.test(line)) return []; return [ { category: "blog-live2d-target-base-transform-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D 已拆 Cubism2 target-base consumer 不应直接调用 targetBaseData._$nb 旧 point-transform alias。", project: context.project, suggestion: "改用 targetBaseData.transformPoints(...);_$nb 定义侧残留只表示迁移未完成,收敛 BaseData/Transform/Grid 依赖簇时必须删除。", }, ]; }); } const blogLive2DPhysicsPointConsumerFiles = new Set([ "src/components/blog/live2d/vendor/cubism2Core/physicsHair.ts", ]); const blogLive2DPhysicsPointAliasConsumerPattern = /\bthis\.p[12]\._\$(?:p|s0|70|7L|HL|xT)\b/; /** * Finds restored Blog Live2D Cubism2 PhysicsHair point integration that regresses to legacy PhysicsPoint fields or snapshot methods. * @param lines - Source lines from `physicsHair.ts`, where hair integration must use semantic state even while point aliases remain migration debt. * @param context - Project and file identity used to restrict the check to the Blog Web Cubism2 compatibility module. * @returns Review findings for direct `this.p1._$...` or `this.p2._$...` PhysicsPoint consumer access. */ export function findBlogLive2DPhysicsPointAliasConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || !blogLive2DPhysicsPointConsumerFiles.has(normalized) ) { return []; } return lines.flatMap((line, index) => { if (!blogLive2DPhysicsPointAliasConsumerPattern.test(line)) return []; return [ { category: "blog-live2d-physics-point-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D PhysicsHair point integration 不应直接消费 PhysicsPoint._$p/_$s0/_$70/_$7L/_$HL/_$xT 旧 alias。", project: context.project, suggestion: "改用 mass、previousX、previousY、previousVelocityX、previousVelocityY 和 capturePreviousState;PhysicsPoint 短名残留须在该依赖簇收敛时删除。", }, ]; }); } /** * Rejects file-wide TypeScript suppression anywhere in the production Blog Live2D tree. * @param lines Source lines from one changed file. * @param context Project and project-relative file identity. * @returns P1 findings for every production `@ts-nocheck` directive. */ export function findBlogLive2DTypeSuppressionFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || !normalized.startsWith("src/components/blog/live2d/") ) { return []; } return lines.flatMap((line, index) => { if (!/^\s*\/\/\s*@ts-nocheck\b/u.test(line)) return []; return [ { category: "blog-live2d-type-suppression", file: context.file, line: index + 1, level: "P1" as const, message: "Blog Live2D 生产源码不得使用整文件 @ts-nocheck 绕过类型检查。", project: context.project, suggestion: "从语义接口和调用方源头修正类型,不得恢复兼容映射或文件级抑制。", }, ]; }); } const blogLive2DLive2DErrorStateConsumerFile = "src/components/blog/live2d/vendor/cubism2Core/modelContext.ts"; /** * Finds restored Blog Live2D Cubism2 consumers that regress from the semantic runtime error setter to a residual min.js alias. * @param lines - Source lines from `modelContext.ts`, where draw-update failures should report through the semantic Live2D API. * @param context - Project and file identity; this transitional check targets ModelContext while the final zero-residual audit removes the definition-side short name. * @returns Review findings for direct `Live2D._$sT(...)` calls in the extracted consumer module. */ export function findBlogLive2DLive2DErrorStateConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || normalized !== blogLive2DLive2DErrorStateConsumerFile ) { return []; } return lines.flatMap((line, index) => { if (!/\bLive2D\._\$sT\s*\(/.test(line)) return []; return [ { category: "blog-live2d-live2d-error-state-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D ModelContext 不应直接调用 Live2D._$sT 旧 error setter alias。", project: context.project, suggestion: "改用 Live2D.setErrorCode(...);live2dRuntime.ts 中的 _$sT 残留须在该依赖簇收敛时删除。", }, ]; }); } const blogLive2DClippedOpacityFlagConsumerFile = "src/components/blog/live2d/vendor/cubism2Core/drawData.ts"; /** * Finds restored Blog Live2D Cubism2 draw-data consumers that regress from the semantic clipped-opacity flag to a residual min.js alias. * @param lines - Source lines from `drawData.ts`, where draw-context opacity gating should read the semantic Live2D flag. * @param context - Project and file identity; this transitional check targets DrawData while the final zero-residual audit removes the definition-side short name. * @returns Review findings for direct `Live2D._$Zs` reads in the extracted draw-data consumer. */ export function findBlogLive2DClippedOpacityFlagConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || normalized !== blogLive2DClippedOpacityFlagConsumerFile ) { return []; } return lines.flatMap((line, index) => { if (!/\bLive2D\._\$Zs\b/.test(line)) return []; return [ { category: "blog-live2d-clipped-opacity-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D DrawData 不应直接读取 Live2D._$Zs 旧 clipped opacity flag。", project: context.project, suggestion: "改用 Live2D.shouldUpdateClippedDrawContextOpacity;live2dRuntime.ts 中的 _$Zs 残留须在该依赖簇收敛时删除。", }, ]; }); } const blogLive2DInvalidInterpolationCornerFlagConsumerFile = "src/components/blog/live2d/vendor/cubism2Core/paramBinding.ts"; /** * Finds restored Blog Live2D Cubism2 param-binding consumers that regress from the semantic invalid-corner guard to a residual min.js alias. * @param lines - Source lines from `paramBinding.ts`, where interpolation corner validation should read the semantic Live2D flag. * @param context - Project and file identity; this transitional check targets ParamBinding while the final zero-residual audit removes the definition-side short name. * @returns Review findings for direct `Live2D._$3T` reads in the extracted ParamBinding consumer. */ export function findBlogLive2DInvalidInterpolationCornerFlagConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || normalized !== blogLive2DInvalidInterpolationCornerFlagConsumerFile ) { return []; } return lines.flatMap((line, index) => { if (!/\bLive2D\._\$3T\b/.test(line)) return []; return [ { category: "blog-live2d-invalid-interpolation-corner-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D ParamBinding 不应直接读取 Live2D._$3T 旧 invalid interpolation corner flag。", project: context.project, suggestion: "改用 Live2D.shouldThrowOnInvalidInterpolationCorner;live2dRuntime.ts 中的 _$3T 残留须在该依赖簇收敛时删除。", }, ]; }); } const blogLive2DSdk1GridClampFlagConsumerFile = "src/components/blog/live2d/vendor/cubism2Core/gridBaseData.ts"; /** * Finds restored Blog Live2D Cubism2 grid consumers that regress from the semantic SDK1 unit-range clamp flag to a residual min.js alias. * @param lines - Source lines from `gridBaseData.ts`, where SDK1 point transforms and dependency contracts should expose only the semantic Live2D clamp flag. * @param context - Project and file identity; this transitional check targets GridBaseData while the final zero-residual audit removes the definition-side short name. * @returns Review findings for direct `Live2D._$ts` reads or `_$ts` type-contract leaks in the extracted GridBaseData consumer. */ export function findBlogLive2DSdk1GridClampFlagConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || normalized !== blogLive2DSdk1GridClampFlagConsumerFile ) { return []; } return lines.flatMap((line, index) => { if (!/\bLive2D\._\$ts\b|^\s*_\$ts\s*:/.test(line)) return []; return [ { category: "blog-live2d-sdk1-grid-clamp-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D GridBaseData 不应直接读取或暴露 Live2D._$ts 旧 SDK1 grid clamp flag。", project: context.project, suggestion: "改用 Live2D.shouldClampSdk1GridPointsToUnitRange;live2dRuntime.ts 中的 _$ts 残留须在该依赖簇收敛时删除。", }, ]; }); } const blogLive2DVerboseLoggingConsumerFiles = new Set([ "src/components/blog/live2d/vendor/cubism2Core/drawData.ts", "src/components/blog/live2d/vendor/cubism2Core/gridBaseData.ts", "src/components/blog/live2d/vendor/cubism2Core/runtimeCore.ts", "src/components/blog/live2d/vendor/cubism2Core/transformBaseData.ts", ]); /** * Finds restored Blog Live2D Cubism2 consumers that regress from the semantic verbose logging method to a residual min.js alias. * @param lines - Source lines from an extracted Cubism2 compatibility module, where dependency contracts should expose the semantic verbose logging reader. * @param context - Project and file identity; this transitional check targets consumers while the final zero-residual audit removes the definition-side short name. * @returns Review findings for direct `Live2D._$so` reads or `_$so` type-contract leaks in extracted Live2D consumers. */ export function findBlogLive2DVerboseLoggingConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || !blogLive2DVerboseLoggingConsumerFiles.has(normalized) ) { return []; } return lines.flatMap((line, index) => { if (!/\bLive2D\._\$so\b|^\s*_\$so\s*:/.test(line)) return []; return [ { category: "blog-live2d-verbose-logging-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D 已拆 Cubism2 模块不应直接读取或暴露 Live2D._$so 旧 verbose logging flag。", project: context.project, suggestion: "改用 Live2D.isVerboseLoggingEnabled();live2dRuntime.ts 中的 _$so 残留须在该依赖簇收敛时删除。", }, ]; }); } const blogLive2DUtSystemTimeStateFile = "src/components/blog/live2d/vendor/cubism2Core/runtimeUtilities.ts"; const blogLive2DUtSystemArrayCopyConsumerFiles = new Set([ "src/components/blog/live2d/vendor/cubism2Core/affineTransform.ts", "src/components/blog/live2d/vendor/cubism2Core/canvasDrawParam.ts", "src/components/blog/live2d/vendor/cubism2Core/interpolation.ts", "src/components/blog/live2d/vendor/cubism2Core/modelContext.ts", ]); /** * Finds restored UtSystem runtime utilities that regress semantic user-time state back to min.js fields as the primary implementation. * @param lines - Source lines from `runtimeUtilities.ts`, where `NO_USER_TIME_SENTINEL` and `userTimeMillis` should own time state. * @param context - Project and file identity; this transitional check protects semantic state ownership while the final zero-residual audit removes descriptor aliases. * @returns Review findings for direct legacy-field primary state initialization or user-time reads/writes. */ export function findBlogLive2DUtSystemTimeStateFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || normalized !== blogLive2DUtSystemTimeStateFile ) { return []; } return lines.flatMap((line, index) => { if (!/\bSystemRuntime\._\$(?:CS|W2)\b/.test(line)) return []; return [ { category: "blog-live2d-utsystem-time-alias-state", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D UtSystem 已恢复语义时间状态,runtimeUtilities.ts 不应把 _$CS/_$W2 当成主状态读写。", project: context.project, suggestion: "改用 NO_USER_TIME_SENTINEL / userTimeMillis;_$CS/_$W2 同步描述符只属迁移债务,收敛该依赖簇时必须删除。", }, ]; }); } /** * Finds restored UtDebug runtime utilities that regress semantic timer/debug state back to min.js fields as the primary implementation. * @param lines - Source lines from `runtimeUtilities.ts`, where `debugLevel`, `timerRecords`, `timerName`, and `startedAtMillis` should own timer state. * @param context - Project and file identity; this transitional check protects semantic timer ownership while the final zero-residual audit removes descriptor aliases. * @returns Review findings for direct legacy-field primary state initialization or timer reads/writes. */ export function findBlogLive2DUtDebugTimerStateFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || normalized !== blogLive2DUtSystemTimeStateFile ) { return []; } const legacyStaticStatePattern = /\bDebugRuntime\._\$(?:8s|fT)\b/; const legacyTimerRecordStatePattern = /(?:\bthis\b|\b[A-Za-z_$][\w$]*)\._\$(?:0S|r)\b/; return lines.flatMap((line, index) => { if ( !legacyStaticStatePattern.test(line) && !legacyTimerRecordStatePattern.test(line) ) { return []; } return [ { category: "blog-live2d-utdebug-timer-alias-state", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D UtDebug 已恢复语义 timer/debug 状态,runtimeUtilities.ts 不应把 _$8s/_$fT/_$r/_$0S 当成主状态读写。", project: context.project, suggestion: "改用 debugLevel / timerRecords / timerName / startedAtMillis;旧同步字段只属迁移债务,收敛该依赖簇时必须删除。", }, ]; }); } /** * Finds restored UtDebug dump helpers that regress to min.js alias names as primary function implementations. * @param lines - Source lines from `runtimeUtilities.ts`, where dump helper bodies should live behind semantic names. * @param context - Project and file identity; this transitional check protects semantic helper ownership while the final zero-residual audit removes identity aliases. * @returns Review findings for direct legacy helper function assignments. */ export function findBlogLive2DUtDebugDumpHelperFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || normalized !== blogLive2DUtSystemTimeStateFile ) { return []; } return lines.flatMap((line, index) => { if (!/\bDebugRuntime\._\$(?:dL|KL|nr)\s*=\s*function\b/.test(line)) { return []; } return [ { category: "blog-live2d-utdebug-dump-helper-alias", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D UtDebug dump helpers 已恢复语义入口,runtimeUtilities.ts 不应把 _$dL/_$KL/_$nr 作为主函数实现。", project: context.project, suggestion: "改用 logDebugWithBlankLine / dumpHexBytes / dumpArrayValues;旧 identity alias 只属迁移债务,收敛该依赖簇时必须删除。", }, ]; }); } /** * Finds restored Blog Live2D Cubism2 consumers that regress from semantic array-copy routing to the residual min.js `UtSystem._$jT` alias. * @param lines - Source lines from an extracted Cubism2 consumer module, where dependency contracts should expose `copyArraySegment`. * @param context - Project and file identity; this transitional check targets consumers while the final zero-residual audit removes the definition-side identity alias. * @returns Review findings for direct `UtSystem._$jT` calls or dependency contracts that leak `_$jT`. */ export function findBlogLive2DUtSystemArrayCopyConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || !blogLive2DUtSystemArrayCopyConsumerFiles.has(normalized) ) { return []; } return lines.flatMap((line, index) => { if ( !/\b(?:options\.)?UtSystem\._\$jT\b|^\s*_\$jT\s*:|['"]_\$jT['"]/.test(line) ) { return []; } return [ { category: "blog-live2d-utsystem-array-copy-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D 已拆 Cubism2 consumer 不应直接调用或暴露 UtSystem._$jT 数组复制 alias。", project: context.project, suggestion: "改用 UtSystem.copyArraySegment;runtimeUtilities.ts 中的 _$jT 残留须在该依赖簇收敛时删除。", }, ]; }); } const blogLive2DUtDebugExceptionConsumerFiles = new Set([ "src/components/blog/live2d/vendor/cubism2Core/ldgl.ts", "src/components/blog/live2d/vendor/cubism2Core/modelBase.ts", "src/components/blog/live2d/vendor/cubism2Core/modelContext.ts", ]); /** * Finds restored Blog Live2D Cubism2 exception consumers that regress from semantic logging to a residual min.js alias. * @param lines - Source lines from the reviewed Cubism2 compatibility module. * @param context - Project and file identity; this transitional check targets consumers while the final zero-residual audit removes the definition-side alias. * @returns Review findings for direct `UtDebug._$Rb(error)` calls in extracted consumer modules. */ export function findBlogLive2DUtDebugExceptionConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || !blogLive2DUtDebugExceptionConsumerFiles.has(normalized) ) { return []; } return lines.flatMap((line, index) => { if (!/\bUtDebug\._\$Rb\s*\(/.test(line)) return []; return [ { category: "blog-live2d-utdebug-exception-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D 已拆 Cubism2 模块不应直接消费 UtDebug._$Rb exception dump 旧 alias。", project: context.project, suggestion: "改用 UtDebug.logException(error);_$Rb 定义侧残留只表示迁移未完成,收敛该依赖簇时必须删除。", }, ]; }); } const blogLive2DModelBaseMocVersionConsumerFile = "src/components/blog/live2d/vendor/cubism2Core/modelBase.ts"; const blogLive2DDrawDataMocVersionConsumerFile = "src/components/blog/live2d/vendor/cubism2Core/drawData.ts"; /** * Finds restored Blog Live2D ModelBase MOC-version load guards that regress from semantic static names to residual min.js aliases. * @param lines - Source lines from `modelBase.ts`, where MOC format guards should read semantic `Cubism2MocVersion` constants. * @param context - Project and file identity; this transitional check targets ModelBase while the final zero-residual audit removes definition-side aliases. * @returns Review findings for direct `Cubism2MocVersion._$T7/_$s7` reads or type-contract leaks. */ export function findBlogLive2DModelBaseMocVersionConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || normalized !== blogLive2DModelBaseMocVersionConsumerFile ) { return []; } return lines.flatMap((line, index) => { if ( !/\bCubism2MocVersion\._\$(?:T7|s7)\b|^\s*_\$(?:T7|s7)\s*:/.test( line, ) ) { return []; } return [ { category: "blog-live2d-modelbase-mocversion-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D ModelBase 不应直接消费 Cubism2MocVersion._$T7/_$s7 旧 MOC 版本 alias。", project: context.project, suggestion: "改用 MAX_SUPPORTED_FORMAT_VERSION 和 LIVE2D_FORMAT_VERSION_WITH_CHECKSUM_MARKER;coreTypes.ts 中的短名残留须在该依赖簇收敛时删除。", }, ]; }); } /** * Finds restored Blog Live2D DrawData MOC-version gates that regress from semantic static names to residual min.js aliases. * @param lines - Source lines from `drawData.ts`, where draw-data clip and mesh-flag gates should read semantic `Cubism2MocVersion` constants. * @param context - Project and file identity; this transitional check targets DrawData while the final zero-residual audit removes definition-side aliases. * @returns Review findings for direct `Cubism2MocVersion._$T7/_$s7` reads or type-contract leaks. */ export function findBlogLive2DDrawDataMocVersionConsumerFindings( lines: string[], context: { file: string; project: string; }, ): ReviewFinding[] { const normalized = context.file.replaceAll("\\", "/"); if ( context.project !== "Vue/kt-blog-web" || normalized !== blogLive2DDrawDataMocVersionConsumerFile ) { return []; } return lines.flatMap((line, index) => { if ( !/\bCubism2MocVersion\._\$(?:T7|s7)\b|^\s*_\$(?:T7|s7)\s*:/.test( line, ) ) { return []; } return [ { category: "blog-live2d-drawdata-mocversion-alias-consumer", file: context.file, line: index + 1, level: "P2" as const, message: "Blog Live2D DrawData 不应直接消费 Cubism2MocVersion._$T7/_$s7 旧 MOC 版本 alias。", project: context.project, suggestion: "改用 MAX_SUPPORTED_FORMAT_VERSION 和 LIVE2D_FORMAT_VERSION_WITH_CHECKSUM_MARKER;coreTypes.ts 中的短名残留须在该依赖簇收敛时删除。", }, ]; }); } 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); } /** * Reads one reviewable project file while bounding generic content scans. * @param projectPath Absolute project root used to resolve the relative file path. * @param file Project-relative file selected by the review scanner. * @returns UTF-8 lines, or an empty array for missing, non-file, oversized, or unreadable inputs. */ 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 > reviewContentMaxFileBytes) 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) ); } /** * Checks whether runtime debug output is allowed for a known generated or compatibility boundary. * @param projectAlias KT project alias used to distinguish app code from workflow scripts. * @param file Project-relative path currently being scanned for console/debugger statements. * @returns True only for documentation or files where console output is intentional framework, script, or min.js compatibility behavior. */ export function isRuntimeDebugAllowed( projectAlias: ProjectAlias | null, file: string, ): boolean { const normalized = file.replaceAll("\\", "/"); if (/^(README|docs\/|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; } if ( projectAlias === "blog" && normalized.startsWith( "src/components/blog/live2d/vendor/cubism2Core/", ) ) { 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); } /** * Distinguishes intentionally tracked public frontend env files from backend secret-bearing env files. * @param projectAlias - Known KT project alias, or null for an arbitrary path. * @param file - Repository-relative tracked file path. * @returns True only for examples, frontend-only repositories, or NapCat's Vite frontend package. */ export function isAllowedSensitiveTrackedFile( projectAlias: ProjectAlias | null, file: string, ): boolean { if (allowedSensitiveTrackedFilePattern.test(file)) return true; if (projectAlias && frontendEnvProjectAliases.has(projectAlias)) { return /(^|\/)\.env($|\..+)/i.test(file); } if (projectAlias === "napcat") { const normalized = file.replaceAll("\\", "/"); return /^packages\/napcat-webui-frontend\/\.env($|\.)/i.test(normalized); } 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) { 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) { if (/\.(tsx|jsx|vue)$/.test(file)) { findings.push( ...findContextDependentVueTsxRenderHelpers(lines.join("\n")).map( (helper) => ({ category: "vue-tsx-render-helper", file, line: helper.line, level: "P2" as const, message: `Vue TSX render helper ${helper.name} 依赖动态 this。`, project: project.relativePath, suggestion: "改为箭头函数或移除动态 this 依赖。", }), ), ); } findings.push( ...findNapcatImageGovernanceFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DBaseDataIdConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DRuntimeConstantsConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DTargetBaseTransformConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DPhysicsPointAliasConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DTypeSuppressionFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DLive2DErrorStateConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DClippedOpacityFlagConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DInvalidInterpolationCornerFlagConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DSdk1GridClampFlagConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DVerboseLoggingConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DUtSystemTimeStateFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DUtDebugTimerStateFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DUtDebugDumpHelperFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DUtSystemArrayCopyConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DUtDebugExceptionConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DModelBaseMocVersionConsumerFindings(lines, { file, project: project.relativePath, }), ...findBlogLive2DDrawDataMocVersionConsumerFindings(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); } /** * Finds files that would make `workspace-assets` behave like another runtime * dump/cache directory instead of a small reusable asset store. * * @returns Review findings for large, archived, dump-like, or secret-like files * found under `workspace-assets`. */ function findWorkspaceAssetPolicyFindings(): ReviewFinding[] { const assetRoot = path.join(workspaceRoot, "workspace-assets"); if (!existsSync(assetRoot)) return []; const findings: ReviewFinding[] = []; const walk = (currentPath: string) => { for (const entry of readdirSync(currentPath, { withFileTypes: true })) { const entryPath = path.join(currentPath, entry.name); const relativePath = toPosix(path.relative(workspaceRoot, entryPath)); if (entry.isDirectory()) { if (reviewSkipDirectoryNames.has(entry.name)) continue; walk(entryPath); continue; } if (!entry.isFile()) continue; const stats = statSync(entryPath); if (workspaceAssetBlockedFilePattern.test(relativePath)) { findings.push({ category: "workspace-assets-blocked-file", file: relativePath, level: "P1", message: "workspace-assets 中出现禁止跟踪的归档、dump、环境或密钥类文件。", project: "root", suggestion: "移回 .kt-workspace 或外部安全存储;workspace-assets 只保留小型可复用资产。", }); continue; } if (stats.size > workspaceAssetMaxFileBytes) { findings.push({ category: "workspace-assets-large-file", file: relativePath, level: "P2", message: "workspace-assets 中出现超过 2MB 的文件,可能是缓存或第三方源码。", project: "root", suggestion: "确认是否必须 Git 管理;大体积参考材料应放到外部仓库或 .kt-workspace 缓存。", }); } } }; walk(assetRoot); return findings; } 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", "--untracked-files=all"], 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, }; } /** * Builds the read-only KT global review across configured projects and root governance documents. * @param input Project selection, scan mode, root-scan toggle, and finding limits supplied by the MCP/CLI caller. * @param documentationRoot Root used only for TASKS and Live2D ledger integration tests; production callers use the KT workspace root. * @returns Aggregated project status, risk scans, root findings, and summary counts. */ export async function buildGlobalCodeReview( input: GlobalCodeReviewInput = {}, documentationRoot: string = workspaceRoot, ): 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 workspaceAssetPolicyFindings = input.includeRootScan === false ? [] : findWorkspaceAssetPolicyFindings(); const taskRecordFindings = input.includeRootScan === false ? [] : findTaskRecordGovernanceFindings( readProjectFileLines(documentationRoot, "TASKS.md"), ); const blogLive2DDeobfuscationLedgerFindings = input.includeRootScan === false ? [] : findBlogLive2DDeobfuscationLedgerFileFindings(documentationRoot); 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;可复用资产迁入 workspace-assets,并更新生成脚本或测试流程。", }), ); const allFindings = [ ...findings, ...rootFindings, ...rootGeneratedFindings, ...workspaceAssetPolicyFindings, ...taskRecordFindings, ...blogLive2DDeobfuscationLedgerFindings, ]; 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, }; }