ktworkflow-mcp/src/tools/review.ts

2423 lines
84 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import type {
GlobalCodeReviewInput,
ProjectAlias,
RepoType,
ResolvedProject,
} from "../types.js";
import { tryExecFile } from "../core/exec.js";
import { detectRepoType, parseGitStatusFiles } from "../core/project.js";
import { 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<ProjectAlias>([
"admin",
"blog",
"playground",
"web",
]);
const credentialAssignmentPattern =
/\b(password|passwd|secret|token|webuiToken|accessToken|privateKey)\b\s*[:=]\s*(["'])([^"']+)\2/i;
const napcatImageAssignmentPattern =
/^\s*QQBOT_NAPCAT_IMAGE\s*=\s*([^#\s]+)\s*(?:#.*)?$/;
const taskRecordAllowedFields = new Set(["范围", "关键词", "验证"]);
const taskRecordRequiredFields = ["范围", "关键词", "验证"] as const;
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<string, unknown>;
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<string>;
heading: string;
line: number;
} | null = null;
const pushMissingFieldFindings = () => {
if (!currentRecord) return;
for (const field of taskRecordRequiredFields) {
if (currentRecord.fields.has(field)) continue;
findings.push({
category: "tasks-record-missing-field",
file: "TASKS.md",
line: currentRecord.line,
level: "P2",
message: `TASKS 最近记录缺少「${field}」字段。`,
project: "root",
suggestion:
"最近记录只保留范围、关键词、验证三项,详细过程迁入 docs 或历史索引。",
});
}
};
for (let index = recentStart + 1; index < recentEnd; index += 1) {
const line = lines[index].trim();
if (line.startsWith("### ")) {
pushMissingFieldFindings();
currentRecord = {
fields: new Set<string>(),
heading: line.replace(/^###\s+/, ""),
line: index + 1,
};
continue;
}
if (!currentRecord) continue;
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:
"把额外说明迁入 docsTASKS 最近记录只保留范围、关键词、验证。",
});
}
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:
"删除流水账、长验证、截图清单和完整命令;必要细节放入 docsTASKS 只保留索引。",
});
}
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<number> |` 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<number> | 主题 | 动作/改动 | 恢复结果 | 证据` 单行,避免重复保存执行过程。",
});
}
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 和 capturePreviousStatePhysicsPoint 短名残留须在该依赖簇收敛时删除。",
},
];
});
}
/**
* 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.shouldUpdateClippedDrawContextOpacitylive2dRuntime.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.shouldThrowOnInvalidInterpolationCornerlive2dRuntime.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.shouldClampSdk1GridPointsToUnitRangelive2dRuntime.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.copyArraySegmentruntimeUtilities.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_MARKERcoreTypes.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_MARKERcoreTypes.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<string>();
let match: RegExpExecArray | null;
while ((match = pluginImportPattern.exec(source))) {
const pluginName = match[2];
if (seenPlugins.has(pluginName)) continue;
seenPlugins.add(pluginName);
const escapedPluginName = pluginName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const hasMock = new RegExp(
`jest\\.mock\\s*\\(\\s*['"][^'"]*/modules/qqbot/plugins/${escapedPluginName}/`,
"m",
).test(source);
if (hasMock) continue;
const lineIndex = lines.findIndex((line) => line.includes(match?.[1] || ""));
findings.push({
category: "qqbot-plugin-smoke-heavy-import",
file: context.file,
line: lineIndex >= 0 ? lineIndex + 1 : undefined,
level: "P2" as const,
message:
"QQBot 插件 registry/controller 契约或 HTTP smoke 导入真实插件实现但未 mock容易拖入渲染、定时器或外部请求依赖。",
project: context.project,
suggestion:
"用 jest.mock mock 具体插件 service token只测试 registry/controller 的别名解析和路由包装边界。",
});
}
return findings;
}
/**
* Finds regressions that would make descriptor-backed QQBot built-in plugins disappear from production dist images.
* @param lines - Source lines from the package path policy file being reviewed.
* @param context - Project and file identity for precise review findings.
* @returns Findings when the policy no longer supports production dist roots or compiled JavaScript entries.
*/
export function findQqbotPluginPackageProductionPathFindings(
lines: string[],
context: {
file: string;
project: string;
},
): ReviewFinding[] {
const normalized = context.file.replaceAll("\\", "/");
if (
context.project !== "Node/kt-template-online-api" ||
normalized !==
"src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service.ts"
) {
return [];
}
const content = lines.join("\n");
const findings: ReviewFinding[] = [];
const hasProductionDistRoot =
content.includes("'dist'") &&
content.includes("'modules'") &&
content.includes("'qqbot'") &&
content.includes("'plugins'");
const hasCompiledEntryFallback =
content.includes("resolveCompiledEntryFile") &&
content.includes("entryFile.endsWith('.ts')") &&
content.includes("}.js`");
if (!hasProductionDistRoot) {
findings.push({
category: "qqbot-plugin-production-dist-root-missing",
file: context.file,
line: 1,
level: "P2",
message:
"QQBot 插件包路径策略缺少生产 dist 根,生产镜像只复制 dist 时内置插件会发现为空。",
project: context.project,
suggestion:
"默认受控根应在源码根不存在时扫描 dist/modules/qqbot/plugins并保留源码态优先顺序。",
});
}
if (!hasCompiledEntryFallback) {
findings.push({
category: "qqbot-plugin-compiled-entry-fallback-missing",
file: context.file,
line: 1,
level: "P2",
message:
"QQBot 插件 manifest 的 src/index.ts entry 缺少生产编译后 .js sibling fallback。",
project: context.project,
suggestion:
"entry 仍应保持源码结构声明,但生产运行时要在同一 package root 内解析到编译后的 src/index.js。",
});
}
return findings;
}
export function findQqbotStatusBoundaryFindings(
lines: string[],
context: {
file: string;
project: string;
},
): ReviewFinding[] {
const normalized = context.file.replaceAll("\\", "/");
if (
context.project !== "Node/kt-template-online-api" ||
!normalized.startsWith("src/qqbot/")
) {
return [];
}
const findings: ReviewFinding[] = [];
if (normalized === "src/qqbot/account/qqbot-account.service.ts") {
let markOnlineWindow = 0;
lines.forEach((line, index) => {
if (/async\s+markOnline\s*\(/.test(line)) {
markOnlineWindow = 20;
}
if (markOnlineWindow > 0) {
if (/\blastError\b.*=\s*null/.test(line)) {
findings.push({
category: "qqbot-mark-online-clears-login-error",
file: context.file,
line: index + 1,
level: "P2",
message:
"markOnline 默认清理 lastError会把 OneBot 重连误当成 QQ 登录恢复。",
project: context.project,
suggestion:
"让 markOnline 默认只更新 OneBot 连接字段;只有 WebUI/自动登录确认 QQ 在线时显式清理 QQ 登录态错误。",
});
}
markOnlineWindow -= 1;
}
if (/account\.connectStatus\s*===\s*['"]online['"].*return\s*['"]online['"]/.test(line)) {
findings.push({
category: "qqbot-connect-status-drives-login-status",
file: context.file,
line: index + 1,
level: "P2",
message:
"QQ 登录态不能从 connectStatus/OneBot 连接态推导为 online。",
project: context.project,
suggestion:
"qqLoginStatus 只根据 NapCat WebUI 登录状态或明确登录日志判断connectStatus 只代表 OneBot 连接。",
});
}
if (/toTime\(account\.lastHeartbeatAt\)/.test(line)) {
findings.push({
category: "qqbot-heartbeat-bypasses-login-check",
file: context.file,
line: index + 1,
level: "P2",
message:
"OneBot heartbeat 不能绕过 NapCat QQ 登录态检查。",
project: context.project,
suggestion:
"心跳只证明容器/OneBot 通信活着;陈旧登录态仍要通过 NapCat WebUI/日志刷新。",
});
}
});
}
if (normalized === "src/qqbot/connection/qqbot-reverse-ws.service.ts") {
lines.forEach((line, index) => {
if (!/ws\.on\(['"]close['"]/.test(line)) return;
const body = lines.slice(index, index + 8).join("\n");
if (
body.includes("this.connections.delete(key)") &&
!body.includes("isCurrentConnection")
) {
findings.push({
category: "qqbot-stale-ws-close-marks-offline",
file: context.file,
line: index + 1,
level: "P2",
message:
"WS close 处理没有校验当前连接身份,陈旧连接可能误删新连接并标记 OneBot offline。",
project: context.project,
suggestion:
"close/error/timeout 清理前先确认 map 中的当前连接仍是触发事件的 ws。",
});
}
});
}
return findings;
}
export function findQqbotNapcatCaptchaFlowFindings(
lines: string[],
context: {
file: string;
project: string;
},
): ReviewFinding[] {
const normalized = context.file.replaceAll("\\", "/");
if (
context.project === "Node/kt-template-online-api" &&
normalized === "src/qqbot/napcat/qqbot-napcat-container.service.ts"
) {
const detectLine = lines.findIndex((line) =>
/async\s+detectRuntimeCaptchaUrl\s*\(/.test(line),
);
const detectBody =
detectLine >= 0 ? lines.slice(detectLine, detectLine + 45).join("\n") : "";
const findings: ReviewFinding[] = [];
if (
detectBody &&
(detectBody.includes("this.getRuntimeCheckTimeoutMs()") ||
!detectBody.includes("this.getCaptchaLogReadTimeoutMs()"))
) {
findings.push({
category: "qqbot-napcat-captcha-log-timeout-too-short",
file: context.file,
line: detectLine + 1,
level: "P2",
message:
"NapCat 验证码日志读取复用了 5 秒运行态快检超时API Pod 到 NAS 的 SSH 读日志可能被提前杀掉。",
project: context.project,
suggestion:
"detectRuntimeCaptchaUrl 使用独立的验证码日志读取超时,默认不要低于 15 秒,避免 proofWaterUrl 已存在但被 SSH 超时吞掉。",
});
}
return findings;
}
if (
context.project !== "Node/kt-template-online-api" ||
normalized !== "src/qqbot/account/qqbot-napcat-login.service.ts"
) {
return [];
}
const content = lines.join("\n");
const findings: ReviewFinding[] = [];
const waitLine = lines.findIndex((line) =>
/private\s+async\s+waitForPasswordLoginStatus\s*\(/.test(line),
);
const resolveLine = lines.findIndex((line) =>
/private\s+async\s+resolvePasswordCaptchaUrl\s*\(/.test(line),
);
const helperLine = lines.findIndex((line) =>
/private\s+async\s+detectPasswordCaptchaUrl\s*\(/.test(line),
);
const waitCaptchaLine = lines.findIndex((line) =>
/private\s+async\s+waitForPasswordCaptchaUrl\s*\(/.test(line),
);
const statusLine = lines.findIndex((line) =>
/async\s+status\s*\(\s*sessionId/.test(line),
);
const passwordReloginLine = lines.findIndex((line) =>
/private\s+async\s+tryPasswordRelogin\s*\(/.test(line),
);
const waitBody =
waitLine >= 0 ? lines.slice(waitLine, waitLine + 90).join("\n") : "";
const statusBody =
statusLine >= 0 ? lines.slice(statusLine, statusLine + 90).join("\n") : "";
const passwordReloginBody =
passwordReloginLine >= 0
? lines.slice(passwordReloginLine, passwordReloginLine + 140).join("\n")
: "";
if (
content.includes("waitForPasswordLoginStatus") &&
(!waitBody.includes("isNapcatCaptchaRequiredMessage(errorMessage)") ||
!waitBody.includes("detectPasswordCaptchaUrl("))
) {
findings.push({
category: "qqbot-napcat-captcha-error-throws-before-pending",
file: context.file,
line: waitLine >= 0 ? waitLine + 1 : undefined,
level: "P2",
message:
"NapCat 密码登录验证码错误没有在清理容器前转换为 pending 状态。",
project: context.project,
suggestion:
"捕获 CheckLoginStatus 的验证码错误,转成 loginError 状态,再从当前密码登录容器日志提取 proofWaterUrl。",
});
}
const resolveBody =
resolveLine >= 0 ? lines.slice(resolveLine, resolveLine + 60).join("\n") : "";
const helperBody =
helperLine >= 0 ? lines.slice(helperLine, helperLine + 30).join("\n") : "";
const waitCaptchaBody =
waitCaptchaLine >= 0
? lines.slice(waitCaptchaLine, waitCaptchaLine + 40).join("\n")
: "";
const runtimeLogReads = (
helperBody.match(/detectRuntimeCaptchaUrl\s*\(/g) || []
).length;
const boundedRuntimeLogRead = /detectPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,\s*false\s*,?\s*\)/.test(
resolveBody,
);
const captchaTailFallbackRead = /detectPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,\s*true\s*,?\s*\)/.test(
resolveBody,
);
const captchaWaitFallbackRead = /detectPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,\s*true\s*,?\s*\)/.test(
waitCaptchaBody,
);
if (
resolveBody &&
(!resolveBody.includes("detectPasswordCaptchaUrl(") ||
runtimeLogReads < 2 ||
!boundedRuntimeLogRead ||
(!captchaTailFallbackRead && !captchaWaitFallbackRead))
) {
findings.push({
category: "qqbot-napcat-captcha-log-single-read",
file: context.file,
line: resolveLine + 1,
level: "P2",
message:
"验证码 URL 日志只读一次,时间窗偏差时可能在清理前拿不到 proofWaterUrl。",
project: context.project,
suggestion:
"resolve 阶段调用统一日志提取 helperhelper 先按本次重启时间窗读取日志,再读取当前容器 tail 兜底。",
});
}
const resolveUsesDelayedCaptchaWait = /waitForPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,?\s*\)/.test(
resolveBody,
);
const delayedCaptchaWaitHasLoop =
/\bfor\s*\(|\bwhile\s*\(/.test(waitCaptchaBody) ||
/const\s+attempts\s*=/.test(waitCaptchaBody);
const delayedCaptchaWaitHasSleep =
waitCaptchaBody.includes("this.toolsService.sleep(") &&
waitCaptchaBody.includes("this.getLoginPollIntervalMs()");
if (
resolveBody &&
(!resolveUsesDelayedCaptchaWait ||
!delayedCaptchaWaitHasLoop ||
!captchaWaitFallbackRead ||
!delayedCaptchaWaitHasSleep)
) {
findings.push({
category: "qqbot-napcat-captcha-status-before-log-url",
file: context.file,
line: waitCaptchaLine >= 0 ? waitCaptchaLine + 1 : resolveLine + 1,
level: "P2",
message:
"NapCat 验证码状态可能先于 Docker 日志 URL 到达,直接失败会在清理容器前丢失 proofWaterUrl。",
project: context.project,
suggestion:
"验证码状态确认后,在失败清理运行态密码前用短窗口轮询当前容器日志,拿到 proofWaterUrl 后保持 captcha pending。",
});
}
if (
statusBody.includes("session.captchaUrl") &&
!statusBody.includes("isPasswordCaptchaStillRequired(status)")
) {
findings.push({
category: "qqbot-napcat-captcha-status-clears-pending",
file: context.file,
line: statusLine + 1,
level: "P2",
message:
"NapCat 验证码 pending 后,状态轮询可能把仍需验证码的无 URL 状态误判为失败并清掉 captchaUrl。",
project: context.project,
suggestion:
"status() 在 session 已有 captchaUrl 且当前状态仍提示需要验证码/安全验证时,应继续 keepPasswordCaptchaPending而不是 failCaptchaLogin。",
});
}
const hasLegacyDeviceVerifyPending =
content.includes("keepDeviceVerifyPending") &&
content.includes("deviceVerifyUrl");
const hasNapcatNewDeviceApiFlow =
content.includes("GetNewDeviceQRCode") &&
content.includes("PollNewDeviceQR") &&
content.includes("NewDeviceLogin") &&
content.includes("deviceVerifyUrl") &&
content.includes("newDeviceQrcode") &&
content.includes("newDeviceStatus") &&
content.includes("session.captchaUrl = undefined");
const hasNapcatNewDeviceWrapperFlow =
content.includes("startNewDeviceVerification") &&
content.includes("pollNewDeviceVerification") &&
content.includes("completePasswordLoginAfterChallenge") &&
content.includes("deviceVerifyUrl") &&
content.includes("newDeviceQrcode") &&
content.includes("newDeviceStatus") &&
content.includes("session.captchaUrl = undefined");
if (
content.includes("needNewDevice") &&
!hasLegacyDeviceVerifyPending &&
!hasNapcatNewDeviceApiFlow &&
!hasNapcatNewDeviceWrapperFlow
) {
findings.push({
category: "qqbot-napcat-new-device-drops-pending",
file: context.file,
line:
lines.findIndex((line) => line.includes("needNewDevice")) + 1 ||
undefined,
level: "P2",
message:
"NapCat 验证码通过后仍可能要求新设备验证,直接提示或失败会丢掉可继续的 jumpUrl。",
project: context.project,
suggestion:
"CaptchaLogin 返回 needNewDevice/jumpUrl 时,应保持同一 scan session pending返回 deviceVerifyUrl清掉旧 captchaUrl并继续轮询同一容器。",
});
}
const logAnchorMatch = passwordReloginBody.match(
/const\s+([A-Za-z0-9_]*LogSinceMs)\s*=\s*Date\.now\(\)[\s\S]*?ensureRuntimeLoginEnv/,
);
const logAnchorName = logAnchorMatch?.[1];
const waitUsesLogAnchor =
!!logAnchorName &&
new RegExp(
`waitForPasswordLoginStatus\\s*\\(\\s*container\\s*,\\s*${logAnchorName}\\s*,?\\s*\\)`,
).test(passwordReloginBody);
const resolveUsesLogAnchor =
!!logAnchorName &&
new RegExp(
`resolvePasswordCaptchaUrl\\s*\\(\\s*container\\s*,\\s*loginStatus\\s*,\\s*${logAnchorName}\\s*,?\\s*\\)`,
).test(passwordReloginBody);
if (
passwordReloginBody &&
(!logAnchorName || !waitUsesLogAnchor || !resolveUsesLogAnchor)
) {
findings.push({
category: "qqbot-napcat-captcha-log-window-late",
file: context.file,
line: passwordReloginLine + 1,
level: "P2",
message:
"NapCat 密码验证码日志窗口可能锚在容器重启之后,导致 proofWaterUrl 被漏抓。",
project: context.project,
suggestion:
"在准备 NAPCAT_QUICK_PASSWORD 环境前记录日志窗口起点,并把同一个时间戳传给 waitForPasswordLoginStatus 和 resolvePasswordCaptchaUrl。",
});
}
return findings;
}
async function listGitTrackedFiles(projectPath: string): Promise<string[]> {
const tracked = await tryExecFile("git", ["ls-files"], projectPath);
if (!tracked.ok) return [];
return tracked.stdout
.split(/\r?\n/)
.map((file) => file.trim())
.filter(Boolean)
.map((file) => file.replaceAll("\\", "/"));
}
function shouldReadReviewFile(file: string): boolean {
const normalized = file.replaceAll("\\", "/");
return !reviewSkipFilePattern.test(normalized);
}
/**
* 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<string>,
maxResults: number,
): string[] {
const results: string[] = [];
const walk = (currentPath: string) => {
if (results.length >= maxResults) return;
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
if (results.length >= maxResults) return;
if (entry.isDirectory()) {
if (reviewSkipDirectoryNames.has(entry.name)) continue;
walk(path.join(currentPath, entry.name));
continue;
}
if (entry.isFile() && names.has(entry.name)) {
results.push(
toPosix(
path.relative(workspaceRoot, path.join(currentPath, entry.name)),
),
);
}
}
};
walk(workspaceRoot);
return results;
}
function findRootGeneratedArtifacts(): string[] {
if (!existsSync(workspaceRoot)) return [];
return readdirSync(workspaceRoot, { withFileTypes: true })
.filter((entry) => rootGeneratedArtifactNames.has(entry.name))
.map((entry) => entry.name);
}
/**
* 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<ReviewProjectResult> {
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<ReviewFinding>((file) => ({
category: "tracked-sensitive-file",
file,
level: "P1",
message: "仓库仍跟踪真实环境或部署凭据类文件。",
project: project.relativePath,
suggestion:
"使用 git rm --cached 移出索引,并提交 example 文件或文档说明。",
}));
return {
changedFiles,
contentScannedFiles,
credentialHints: contentFindings.credentialHints,
debugFindings: contentFindings.debugFindings,
findings: [...trackedSensitiveFindings, ...contentFindings.findings],
project,
repoType,
riskScan: await scanTaskRisk({
changedFiles,
project: projectKey,
}),
status: status.ok ? status.stdout : status.stderr,
trackedSensitiveFiles,
};
}
/**
* 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<Record<string, unknown>> {
const projects =
(input.projects?.length ?? 0) > 0
? input.projects!
: [...defaultReviewProjects];
const includeContentScan = input.includeContentScan ?? true;
const contentScanMode = input.contentScanMode ?? "changed";
const maxFindingsPerProject = Math.max(
1,
Math.min(input.maxFindingsPerProject ?? 20, 200),
);
const projectResults = await Promise.all(
projects.map((project) =>
reviewProject(project, {
contentScanMode,
includeContentScan,
maxFindingsPerProject,
}),
),
);
const rootLegacyFiles =
input.includeRootScan === false
? []
: findWorkspaceFilesByName(new Set(["deploy.js", "deploy.json"]), 50);
const rootGeneratedArtifacts =
input.includeRootScan === false ? [] : findRootGeneratedArtifacts();
const 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<ReviewFinding>((file) => ({
category: "legacy-deploy-file",
file,
level: "P2",
message: "发现旧部署遗留文件。",
project: "root",
suggestion: "确认是否仍被脚本引用;无用则删除并从 Git 索引移除。",
}));
const rootGeneratedFindings = rootGeneratedArtifacts.map<ReviewFinding>(
(file) => ({
category: "root-generated-artifact",
file,
level: "P2",
message: "根目录存在生成态临时产物。",
project: "root",
suggestion:
"生成证据迁入 .kt-workspace可复用资产迁入 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,
};
}