2423 lines
84 KiB
TypeScript
2423 lines
84 KiB
TypeScript
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||
import path from "node:path";
|
||
|
||
import type {
|
||
GlobalCodeReviewInput,
|
||
ProjectAlias,
|
||
RepoType,
|
||
ResolvedProject,
|
||
} from "../types.js";
|
||
import { tryExecFile } from "../core/exec.js";
|
||
import { detectRepoType, parseGitStatusFiles } from "../core/project.js";
|
||
import { 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:
|
||
"把额外说明迁入 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<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 和 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<string>();
|
||
let match: RegExpExecArray | null;
|
||
|
||
while ((match = pluginImportPattern.exec(source))) {
|
||
const pluginName = match[2];
|
||
if (seenPlugins.has(pluginName)) continue;
|
||
seenPlugins.add(pluginName);
|
||
|
||
const escapedPluginName = pluginName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
const hasMock = new RegExp(
|
||
`jest\\.mock\\s*\\(\\s*['"][^'"]*/modules/qqbot/plugins/${escapedPluginName}/`,
|
||
"m",
|
||
).test(source);
|
||
if (hasMock) continue;
|
||
|
||
const lineIndex = lines.findIndex((line) => line.includes(match?.[1] || ""));
|
||
findings.push({
|
||
category: "qqbot-plugin-smoke-heavy-import",
|
||
file: context.file,
|
||
line: lineIndex >= 0 ? lineIndex + 1 : undefined,
|
||
level: "P2" as const,
|
||
message:
|
||
"QQBot 插件 registry/controller 契约或 HTTP smoke 导入真实插件实现但未 mock,容易拖入渲染、定时器或外部请求依赖。",
|
||
project: context.project,
|
||
suggestion:
|
||
"用 jest.mock mock 具体插件 service token,只测试 registry/controller 的别名解析和路由包装边界。",
|
||
});
|
||
}
|
||
|
||
return findings;
|
||
}
|
||
|
||
/**
|
||
* Finds regressions that would make descriptor-backed QQBot built-in plugins disappear from production dist images.
|
||
* @param lines - Source lines from the package path policy file being reviewed.
|
||
* @param context - Project and file identity for precise review findings.
|
||
* @returns Findings when the policy no longer supports production dist roots or compiled JavaScript entries.
|
||
*/
|
||
export function findQqbotPluginPackageProductionPathFindings(
|
||
lines: string[],
|
||
context: {
|
||
file: string;
|
||
project: string;
|
||
},
|
||
): ReviewFinding[] {
|
||
const normalized = context.file.replaceAll("\\", "/");
|
||
if (
|
||
context.project !== "Node/kt-template-online-api" ||
|
||
normalized !==
|
||
"src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service.ts"
|
||
) {
|
||
return [];
|
||
}
|
||
|
||
const content = lines.join("\n");
|
||
const findings: ReviewFinding[] = [];
|
||
const hasProductionDistRoot =
|
||
content.includes("'dist'") &&
|
||
content.includes("'modules'") &&
|
||
content.includes("'qqbot'") &&
|
||
content.includes("'plugins'");
|
||
const hasCompiledEntryFallback =
|
||
content.includes("resolveCompiledEntryFile") &&
|
||
content.includes("entryFile.endsWith('.ts')") &&
|
||
content.includes("}.js`");
|
||
|
||
if (!hasProductionDistRoot) {
|
||
findings.push({
|
||
category: "qqbot-plugin-production-dist-root-missing",
|
||
file: context.file,
|
||
line: 1,
|
||
level: "P2",
|
||
message:
|
||
"QQBot 插件包路径策略缺少生产 dist 根,生产镜像只复制 dist 时内置插件会发现为空。",
|
||
project: context.project,
|
||
suggestion:
|
||
"默认受控根应在源码根不存在时扫描 dist/modules/qqbot/plugins,并保留源码态优先顺序。",
|
||
});
|
||
}
|
||
|
||
if (!hasCompiledEntryFallback) {
|
||
findings.push({
|
||
category: "qqbot-plugin-compiled-entry-fallback-missing",
|
||
file: context.file,
|
||
line: 1,
|
||
level: "P2",
|
||
message:
|
||
"QQBot 插件 manifest 的 src/index.ts entry 缺少生产编译后 .js sibling fallback。",
|
||
project: context.project,
|
||
suggestion:
|
||
"entry 仍应保持源码结构声明,但生产运行时要在同一 package root 内解析到编译后的 src/index.js。",
|
||
});
|
||
}
|
||
|
||
return findings;
|
||
}
|
||
|
||
export function findQqbotStatusBoundaryFindings(
|
||
lines: string[],
|
||
context: {
|
||
file: string;
|
||
project: string;
|
||
},
|
||
): ReviewFinding[] {
|
||
const normalized = context.file.replaceAll("\\", "/");
|
||
if (
|
||
context.project !== "Node/kt-template-online-api" ||
|
||
!normalized.startsWith("src/qqbot/")
|
||
) {
|
||
return [];
|
||
}
|
||
|
||
const findings: ReviewFinding[] = [];
|
||
|
||
if (normalized === "src/qqbot/account/qqbot-account.service.ts") {
|
||
let markOnlineWindow = 0;
|
||
lines.forEach((line, index) => {
|
||
if (/async\s+markOnline\s*\(/.test(line)) {
|
||
markOnlineWindow = 20;
|
||
}
|
||
|
||
if (markOnlineWindow > 0) {
|
||
if (/\blastError\b.*=\s*null/.test(line)) {
|
||
findings.push({
|
||
category: "qqbot-mark-online-clears-login-error",
|
||
file: context.file,
|
||
line: index + 1,
|
||
level: "P2",
|
||
message:
|
||
"markOnline 默认清理 lastError,会把 OneBot 重连误当成 QQ 登录恢复。",
|
||
project: context.project,
|
||
suggestion:
|
||
"让 markOnline 默认只更新 OneBot 连接字段;只有 WebUI/自动登录确认 QQ 在线时显式清理 QQ 登录态错误。",
|
||
});
|
||
}
|
||
markOnlineWindow -= 1;
|
||
}
|
||
|
||
if (/account\.connectStatus\s*===\s*['"]online['"].*return\s*['"]online['"]/.test(line)) {
|
||
findings.push({
|
||
category: "qqbot-connect-status-drives-login-status",
|
||
file: context.file,
|
||
line: index + 1,
|
||
level: "P2",
|
||
message:
|
||
"QQ 登录态不能从 connectStatus/OneBot 连接态推导为 online。",
|
||
project: context.project,
|
||
suggestion:
|
||
"qqLoginStatus 只根据 NapCat WebUI 登录状态或明确登录日志判断;connectStatus 只代表 OneBot 连接。",
|
||
});
|
||
}
|
||
|
||
if (/toTime\(account\.lastHeartbeatAt\)/.test(line)) {
|
||
findings.push({
|
||
category: "qqbot-heartbeat-bypasses-login-check",
|
||
file: context.file,
|
||
line: index + 1,
|
||
level: "P2",
|
||
message:
|
||
"OneBot heartbeat 不能绕过 NapCat QQ 登录态检查。",
|
||
project: context.project,
|
||
suggestion:
|
||
"心跳只证明容器/OneBot 通信活着;陈旧登录态仍要通过 NapCat WebUI/日志刷新。",
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
if (normalized === "src/qqbot/connection/qqbot-reverse-ws.service.ts") {
|
||
lines.forEach((line, index) => {
|
||
if (!/ws\.on\(['"]close['"]/.test(line)) return;
|
||
|
||
const body = lines.slice(index, index + 8).join("\n");
|
||
if (
|
||
body.includes("this.connections.delete(key)") &&
|
||
!body.includes("isCurrentConnection")
|
||
) {
|
||
findings.push({
|
||
category: "qqbot-stale-ws-close-marks-offline",
|
||
file: context.file,
|
||
line: index + 1,
|
||
level: "P2",
|
||
message:
|
||
"WS close 处理没有校验当前连接身份,陈旧连接可能误删新连接并标记 OneBot offline。",
|
||
project: context.project,
|
||
suggestion:
|
||
"close/error/timeout 清理前先确认 map 中的当前连接仍是触发事件的 ws。",
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
return findings;
|
||
}
|
||
|
||
export function findQqbotNapcatCaptchaFlowFindings(
|
||
lines: string[],
|
||
context: {
|
||
file: string;
|
||
project: string;
|
||
},
|
||
): ReviewFinding[] {
|
||
const normalized = context.file.replaceAll("\\", "/");
|
||
if (
|
||
context.project === "Node/kt-template-online-api" &&
|
||
normalized === "src/qqbot/napcat/qqbot-napcat-container.service.ts"
|
||
) {
|
||
const detectLine = lines.findIndex((line) =>
|
||
/async\s+detectRuntimeCaptchaUrl\s*\(/.test(line),
|
||
);
|
||
const detectBody =
|
||
detectLine >= 0 ? lines.slice(detectLine, detectLine + 45).join("\n") : "";
|
||
const findings: ReviewFinding[] = [];
|
||
if (
|
||
detectBody &&
|
||
(detectBody.includes("this.getRuntimeCheckTimeoutMs()") ||
|
||
!detectBody.includes("this.getCaptchaLogReadTimeoutMs()"))
|
||
) {
|
||
findings.push({
|
||
category: "qqbot-napcat-captcha-log-timeout-too-short",
|
||
file: context.file,
|
||
line: detectLine + 1,
|
||
level: "P2",
|
||
message:
|
||
"NapCat 验证码日志读取复用了 5 秒运行态快检超时,API Pod 到 NAS 的 SSH 读日志可能被提前杀掉。",
|
||
project: context.project,
|
||
suggestion:
|
||
"detectRuntimeCaptchaUrl 使用独立的验证码日志读取超时,默认不要低于 15 秒,避免 proofWaterUrl 已存在但被 SSH 超时吞掉。",
|
||
});
|
||
}
|
||
return findings;
|
||
}
|
||
if (
|
||
context.project !== "Node/kt-template-online-api" ||
|
||
normalized !== "src/qqbot/account/qqbot-napcat-login.service.ts"
|
||
) {
|
||
return [];
|
||
}
|
||
|
||
const content = lines.join("\n");
|
||
const findings: ReviewFinding[] = [];
|
||
const waitLine = lines.findIndex((line) =>
|
||
/private\s+async\s+waitForPasswordLoginStatus\s*\(/.test(line),
|
||
);
|
||
const resolveLine = lines.findIndex((line) =>
|
||
/private\s+async\s+resolvePasswordCaptchaUrl\s*\(/.test(line),
|
||
);
|
||
const helperLine = lines.findIndex((line) =>
|
||
/private\s+async\s+detectPasswordCaptchaUrl\s*\(/.test(line),
|
||
);
|
||
const waitCaptchaLine = lines.findIndex((line) =>
|
||
/private\s+async\s+waitForPasswordCaptchaUrl\s*\(/.test(line),
|
||
);
|
||
const statusLine = lines.findIndex((line) =>
|
||
/async\s+status\s*\(\s*sessionId/.test(line),
|
||
);
|
||
const passwordReloginLine = lines.findIndex((line) =>
|
||
/private\s+async\s+tryPasswordRelogin\s*\(/.test(line),
|
||
);
|
||
const waitBody =
|
||
waitLine >= 0 ? lines.slice(waitLine, waitLine + 90).join("\n") : "";
|
||
const statusBody =
|
||
statusLine >= 0 ? lines.slice(statusLine, statusLine + 90).join("\n") : "";
|
||
const passwordReloginBody =
|
||
passwordReloginLine >= 0
|
||
? lines.slice(passwordReloginLine, passwordReloginLine + 140).join("\n")
|
||
: "";
|
||
|
||
if (
|
||
content.includes("waitForPasswordLoginStatus") &&
|
||
(!waitBody.includes("isNapcatCaptchaRequiredMessage(errorMessage)") ||
|
||
!waitBody.includes("detectPasswordCaptchaUrl("))
|
||
) {
|
||
findings.push({
|
||
category: "qqbot-napcat-captcha-error-throws-before-pending",
|
||
file: context.file,
|
||
line: waitLine >= 0 ? waitLine + 1 : undefined,
|
||
level: "P2",
|
||
message:
|
||
"NapCat 密码登录验证码错误没有在清理容器前转换为 pending 状态。",
|
||
project: context.project,
|
||
suggestion:
|
||
"捕获 CheckLoginStatus 的验证码错误,转成 loginError 状态,再从当前密码登录容器日志提取 proofWaterUrl。",
|
||
});
|
||
}
|
||
|
||
const resolveBody =
|
||
resolveLine >= 0 ? lines.slice(resolveLine, resolveLine + 60).join("\n") : "";
|
||
const helperBody =
|
||
helperLine >= 0 ? lines.slice(helperLine, helperLine + 30).join("\n") : "";
|
||
const waitCaptchaBody =
|
||
waitCaptchaLine >= 0
|
||
? lines.slice(waitCaptchaLine, waitCaptchaLine + 40).join("\n")
|
||
: "";
|
||
const runtimeLogReads = (
|
||
helperBody.match(/detectRuntimeCaptchaUrl\s*\(/g) || []
|
||
).length;
|
||
const boundedRuntimeLogRead = /detectPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,\s*false\s*,?\s*\)/.test(
|
||
resolveBody,
|
||
);
|
||
const captchaTailFallbackRead = /detectPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,\s*true\s*,?\s*\)/.test(
|
||
resolveBody,
|
||
);
|
||
const captchaWaitFallbackRead = /detectPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,\s*true\s*,?\s*\)/.test(
|
||
waitCaptchaBody,
|
||
);
|
||
if (
|
||
resolveBody &&
|
||
(!resolveBody.includes("detectPasswordCaptchaUrl(") ||
|
||
runtimeLogReads < 2 ||
|
||
!boundedRuntimeLogRead ||
|
||
(!captchaTailFallbackRead && !captchaWaitFallbackRead))
|
||
) {
|
||
findings.push({
|
||
category: "qqbot-napcat-captcha-log-single-read",
|
||
file: context.file,
|
||
line: resolveLine + 1,
|
||
level: "P2",
|
||
message:
|
||
"验证码 URL 日志只读一次,时间窗偏差时可能在清理前拿不到 proofWaterUrl。",
|
||
project: context.project,
|
||
suggestion:
|
||
"resolve 阶段调用统一日志提取 helper;helper 先按本次重启时间窗读取日志,再读取当前容器 tail 兜底。",
|
||
});
|
||
}
|
||
|
||
const resolveUsesDelayedCaptchaWait = /waitForPasswordCaptchaUrl\s*\(\s*container\s*,\s*sinceMs\s*,?\s*\)/.test(
|
||
resolveBody,
|
||
);
|
||
const delayedCaptchaWaitHasLoop =
|
||
/\bfor\s*\(|\bwhile\s*\(/.test(waitCaptchaBody) ||
|
||
/const\s+attempts\s*=/.test(waitCaptchaBody);
|
||
const delayedCaptchaWaitHasSleep =
|
||
waitCaptchaBody.includes("this.toolsService.sleep(") &&
|
||
waitCaptchaBody.includes("this.getLoginPollIntervalMs()");
|
||
if (
|
||
resolveBody &&
|
||
(!resolveUsesDelayedCaptchaWait ||
|
||
!delayedCaptchaWaitHasLoop ||
|
||
!captchaWaitFallbackRead ||
|
||
!delayedCaptchaWaitHasSleep)
|
||
) {
|
||
findings.push({
|
||
category: "qqbot-napcat-captcha-status-before-log-url",
|
||
file: context.file,
|
||
line: waitCaptchaLine >= 0 ? waitCaptchaLine + 1 : resolveLine + 1,
|
||
level: "P2",
|
||
message:
|
||
"NapCat 验证码状态可能先于 Docker 日志 URL 到达,直接失败会在清理容器前丢失 proofWaterUrl。",
|
||
project: context.project,
|
||
suggestion:
|
||
"验证码状态确认后,在失败清理运行态密码前用短窗口轮询当前容器日志,拿到 proofWaterUrl 后保持 captcha pending。",
|
||
});
|
||
}
|
||
|
||
if (
|
||
statusBody.includes("session.captchaUrl") &&
|
||
!statusBody.includes("isPasswordCaptchaStillRequired(status)")
|
||
) {
|
||
findings.push({
|
||
category: "qqbot-napcat-captcha-status-clears-pending",
|
||
file: context.file,
|
||
line: statusLine + 1,
|
||
level: "P2",
|
||
message:
|
||
"NapCat 验证码 pending 后,状态轮询可能把仍需验证码的无 URL 状态误判为失败并清掉 captchaUrl。",
|
||
project: context.project,
|
||
suggestion:
|
||
"status() 在 session 已有 captchaUrl 且当前状态仍提示需要验证码/安全验证时,应继续 keepPasswordCaptchaPending,而不是 failCaptchaLogin。",
|
||
});
|
||
}
|
||
|
||
const hasLegacyDeviceVerifyPending =
|
||
content.includes("keepDeviceVerifyPending") &&
|
||
content.includes("deviceVerifyUrl");
|
||
const hasNapcatNewDeviceApiFlow =
|
||
content.includes("GetNewDeviceQRCode") &&
|
||
content.includes("PollNewDeviceQR") &&
|
||
content.includes("NewDeviceLogin") &&
|
||
content.includes("deviceVerifyUrl") &&
|
||
content.includes("newDeviceQrcode") &&
|
||
content.includes("newDeviceStatus") &&
|
||
content.includes("session.captchaUrl = undefined");
|
||
const hasNapcatNewDeviceWrapperFlow =
|
||
content.includes("startNewDeviceVerification") &&
|
||
content.includes("pollNewDeviceVerification") &&
|
||
content.includes("completePasswordLoginAfterChallenge") &&
|
||
content.includes("deviceVerifyUrl") &&
|
||
content.includes("newDeviceQrcode") &&
|
||
content.includes("newDeviceStatus") &&
|
||
content.includes("session.captchaUrl = undefined");
|
||
if (
|
||
content.includes("needNewDevice") &&
|
||
!hasLegacyDeviceVerifyPending &&
|
||
!hasNapcatNewDeviceApiFlow &&
|
||
!hasNapcatNewDeviceWrapperFlow
|
||
) {
|
||
findings.push({
|
||
category: "qqbot-napcat-new-device-drops-pending",
|
||
file: context.file,
|
||
line:
|
||
lines.findIndex((line) => line.includes("needNewDevice")) + 1 ||
|
||
undefined,
|
||
level: "P2",
|
||
message:
|
||
"NapCat 验证码通过后仍可能要求新设备验证,直接提示或失败会丢掉可继续的 jumpUrl。",
|
||
project: context.project,
|
||
suggestion:
|
||
"CaptchaLogin 返回 needNewDevice/jumpUrl 时,应保持同一 scan session pending,返回 deviceVerifyUrl,清掉旧 captchaUrl,并继续轮询同一容器。",
|
||
});
|
||
}
|
||
|
||
const logAnchorMatch = passwordReloginBody.match(
|
||
/const\s+([A-Za-z0-9_]*LogSinceMs)\s*=\s*Date\.now\(\)[\s\S]*?ensureRuntimeLoginEnv/,
|
||
);
|
||
const logAnchorName = logAnchorMatch?.[1];
|
||
const waitUsesLogAnchor =
|
||
!!logAnchorName &&
|
||
new RegExp(
|
||
`waitForPasswordLoginStatus\\s*\\(\\s*container\\s*,\\s*${logAnchorName}\\s*,?\\s*\\)`,
|
||
).test(passwordReloginBody);
|
||
const resolveUsesLogAnchor =
|
||
!!logAnchorName &&
|
||
new RegExp(
|
||
`resolvePasswordCaptchaUrl\\s*\\(\\s*container\\s*,\\s*loginStatus\\s*,\\s*${logAnchorName}\\s*,?\\s*\\)`,
|
||
).test(passwordReloginBody);
|
||
if (
|
||
passwordReloginBody &&
|
||
(!logAnchorName || !waitUsesLogAnchor || !resolveUsesLogAnchor)
|
||
) {
|
||
findings.push({
|
||
category: "qqbot-napcat-captcha-log-window-late",
|
||
file: context.file,
|
||
line: passwordReloginLine + 1,
|
||
level: "P2",
|
||
message:
|
||
"NapCat 密码验证码日志窗口可能锚在容器重启之后,导致 proofWaterUrl 被漏抓。",
|
||
project: context.project,
|
||
suggestion:
|
||
"在准备 NAPCAT_QUICK_PASSWORD 环境前记录日志窗口起点,并把同一个时间戳传给 waitForPasswordLoginStatus 和 resolvePasswordCaptchaUrl。",
|
||
});
|
||
}
|
||
|
||
return findings;
|
||
}
|
||
|
||
async function listGitTrackedFiles(projectPath: string): Promise<string[]> {
|
||
const tracked = await tryExecFile("git", ["ls-files"], projectPath);
|
||
if (!tracked.ok) return [];
|
||
return tracked.stdout
|
||
.split(/\r?\n/)
|
||
.map((file) => file.trim())
|
||
.filter(Boolean)
|
||
.map((file) => file.replaceAll("\\", "/"));
|
||
}
|
||
|
||
function shouldReadReviewFile(file: string): boolean {
|
||
const normalized = file.replaceAll("\\", "/");
|
||
return !reviewSkipFilePattern.test(normalized);
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
};
|
||
}
|