ktworkflow-mcp/src/tools/review.ts

532 lines
17 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 { resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
import { scanTaskRisk } from './envRisk.js';
export const defaultReviewProjects = [
'api',
'admin',
'blog',
'web',
'playground',
'mcp',
'knife4j',
'fnosK8s',
] as const;
const reviewSkipDirectoryNames = new Set([
'.git',
'.turbo',
'.vite',
'coverage',
'dist',
'node_modules',
'playwright-report',
'test-results',
]);
const reviewSkipFilePattern =
/(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|.*\.map|.*\.png|.*\.jpg|.*\.jpeg|.*\.gif|.*\.webp|.*\.zip|.*\.tar|.*\.gz)$/i;
const rootGeneratedArtifactNames = new Set([
'.codex-db-sync',
'.codex-test-logs',
'.codex-verify',
'.playwright-mcp',
'codex-test-logs',
'test-artifacts',
]);
const sensitiveTrackedFilePattern =
/(^|\/)(\.env($|\..+)|deploy\.json|secrets?\.(env|json|ya?ml)|id_rsa|[^/]+\.(pem|key))$/i;
const allowedSensitiveTrackedFilePattern = /(^|\/)\.env\.example$/i;
const frontendEnvProjectAliases = new Set<ProjectAlias>([
'admin',
'blog',
'playground',
'web',
]);
const credentialAssignmentPattern =
/\b(password|passwd|secret|token|webuiToken|accessToken|privateKey)\b\s*[:=]\s*(["'])([^"']+)\2/i;
const taskRecordAllowedFields = new Set(['范围', '关键词', '验证']);
const taskRecordRequiredFields = ['范围', '关键词', '验证'] as const;
interface ReviewFinding {
category: string;
file?: string;
line?: number;
level: 'P1' | 'P2' | 'P3';
message: string;
project: string;
suggestion: string;
}
interface ReviewProjectResult {
changedFiles: string[];
contentScannedFiles: string[];
credentialHints: ReviewFinding[];
debugFindings: ReviewFinding[];
findings: ReviewFinding[];
project: ResolvedProject;
repoType: RepoType;
riskScan: Record<string, unknown>;
status: string;
trackedSensitiveFiles: string[];
}
export function findTaskRecordGovernanceFindings(lines: string[]): ReviewFinding[] {
const findings: ReviewFinding[] = [];
const recentStart = lines.findIndex((line) => line.trim() === '## 最近记录');
if (recentStart < 0) return findings;
const nextSectionOffset = lines
.slice(recentStart + 1)
.findIndex((line) => /^##\s+/.test(line.trim()));
const recentEnd = nextSectionOffset < 0 ? lines.length : recentStart + 1 + nextSectionOffset;
let currentRecord:
| {
fields: Set<string>;
heading: string;
line: number;
}
| null = null;
const pushMissingFieldFindings = () => {
if (!currentRecord) return;
for (const field of taskRecordRequiredFields) {
if (currentRecord.fields.has(field)) continue;
findings.push({
category: 'tasks-record-missing-field',
file: 'TASKS.md',
line: currentRecord.line,
level: 'P2',
message: `TASKS 最近记录缺少「${field}」字段。`,
project: 'root',
suggestion: '最近记录只保留范围、关键词、验证三项,详细过程迁入 docs 或历史索引。',
});
}
};
for (let index = recentStart + 1; index < recentEnd; index += 1) {
const line = lines[index].trim();
if (line.startsWith('### ')) {
pushMissingFieldFindings();
currentRecord = {
fields: new Set<string>(),
heading: line.replace(/^###\s+/, ''),
line: index + 1,
};
continue;
}
if (!currentRecord) continue;
const fieldMatch = line.match(/^-\s*([^:]+)[:]/);
if (!fieldMatch) continue;
const field = fieldMatch[1].trim();
if (taskRecordAllowedFields.has(field)) {
currentRecord.fields.add(field);
continue;
}
findings.push({
category: 'tasks-record-extra-field',
file: 'TASKS.md',
line: index + 1,
level: 'P2',
message: `TASKS 最近记录「${currentRecord.heading}」包含额外字段「${field}」。`,
project: 'root',
suggestion: '把额外说明迁入 docsTASKS 最近记录只保留范围、关键词、验证。',
});
}
pushMissingFieldFindings();
return findings;
}
async function listGitTrackedFiles(projectPath: string): Promise<string[]> {
const tracked = await tryExecFile('git', ['ls-files'], projectPath);
if (!tracked.ok) return [];
return tracked.stdout
.split(/\r?\n/)
.map((file) => file.trim())
.filter(Boolean)
.map((file) => file.replaceAll('\\', '/'));
}
function shouldReadReviewFile(file: string): boolean {
const normalized = file.replaceAll('\\', '/');
return !reviewSkipFilePattern.test(normalized);
}
function readProjectFileLines(projectPath: string, file: string): string[] {
const filePath = path.join(projectPath, file);
if (!existsSync(filePath)) return [];
try {
const stats = statSync(filePath);
if (!stats.isFile() || stats.size > 1024 * 1024) return [];
return readFileSync(filePath, 'utf8').split(/\r?\n/);
} catch {
return [];
}
}
function isBenignCredentialValue(value: string): boolean {
const normalized = value.trim().replace(/^["']|["']$/g, '').toLowerCase();
if (!normalized) return true;
if (normalized.startsWith('$') || normalized.startsWith('${')) return true;
if (normalized.startsWith('<') || normalized.includes('example')) return true;
if (/^\*+$/.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 isLocalizedDisplayLabelValue(value: string): boolean {
return (
value.length <= 24 &&
/[\u3400-\u9fff]/.test(value) &&
!/[a-z0-9_$./\\:=]/i.test(value)
);
}
function isRuntimeDebugAllowed(projectAlias: ProjectAlias | null, file: string): boolean {
const normalized = file.replaceAll('\\', '/');
if (/^(README|skills\/|.*\.md$)/.test(normalized)) return true;
if (projectAlias === 'admin' && normalized.startsWith('internal/vite-config/')) {
return true;
}
if (projectAlias === 'mcp' && /^(scripts\/|src\/server\.ts$|src\/selfTest\.ts$)/.test(normalized)) {
return true;
}
if (projectAlias === 'fnosK8s' && /^(scripts\/|src\/server\.ts$)/.test(normalized)) {
return true;
}
return false;
}
function isCredentialHintAllowed(file: string, line: string): boolean {
const normalized = file.replaceAll('\\', '/');
const trimmed = line.trimStart();
if (trimmed.startsWith('<')) return true;
if (/\b(url|uri|dsn)\.password\s*=\s*['"]\*+['"]/.test(trimmed)) return true;
return /(^|\/)views\/(demos|examples)\//.test(normalized);
}
function isAllowedSensitiveTrackedFile(projectAlias: ProjectAlias | null, file: string): boolean {
if (allowedSensitiveTrackedFilePattern.test(file)) return true;
if (projectAlias && frontendEnvProjectAliases.has(projectAlias)) {
return /(^|\/)\.env($|\..+)/i.test(file);
}
return false;
}
function collectProjectContentFindings(
project: ResolvedProject,
trackedFiles: string[],
includeContentScan: boolean,
maxFindings: number,
): {
credentialHints: ReviewFinding[];
debugFindings: ReviewFinding[];
findings: ReviewFinding[];
} {
const findings: ReviewFinding[] = [];
const credentialHints: ReviewFinding[] = [];
const debugFindings: ReviewFinding[] = [];
for (const file of trackedFiles.filter(shouldReadReviewFile)) {
const lines = readProjectFileLines(project.path, file);
if (lines.length === 0) continue;
lines.forEach((line, index) => {
if (/^(<<<<<<<|=======|>>>>>>>) /.test(line)) {
findings.push({
category: 'conflict-marker',
file,
line: index + 1,
level: 'P1',
message: '发现 Git 冲突标记。',
project: project.relativePath,
suggestion: '先解决冲突标记,再继续构建或提交。',
});
}
if (!isRuntimeDebugAllowed(project.alias, file) && /\bdebugger;|console\.log\(/.test(line)) {
debugFindings.push({
category: 'runtime-debug',
file,
line: index + 1,
level: /\bdebugger;/.test(line) ? 'P2' : 'P3',
message: '运行时代码中存在调试输出。',
project: project.relativePath,
suggestion: '页面或服务运行时代码不要保留 console.log/debugger。',
});
}
if (includeContentScan) {
if (/\.(tsx|jsx|vue)$/.test(file) && /\bfunction\s+render[A-Z0-9_]/.test(line)) {
findings.push({
category: 'vue-tsx-render-helper',
file,
line: index + 1,
level: 'P2',
message: 'Vue TSX render helper 使用 function 声明。',
project: project.relativePath,
suggestion: '改为箭头函数,并在嵌套组件插槽中显式传 default。',
});
}
const credentialMatch = line.match(credentialAssignmentPattern);
if (
credentialMatch &&
!allowedSensitiveTrackedFilePattern.test(file) &&
!isCredentialHintAllowed(file, line) &&
!isBenignCredentialValue(credentialMatch[3])
) {
credentialHints.push({
category: 'credential-literal',
file,
line: index + 1,
level: 'P2',
message: `疑似 ${credentialMatch[1]} 字面量赋值。`,
project: project.relativePath,
suggestion: '确认是否为真实凭据;真实值应移入本地 env 或远程 Secret。',
});
}
}
});
if (
findings.length + credentialHints.length + debugFindings.length >=
maxFindings * 3
) {
break;
}
}
return {
credentialHints: credentialHints.slice(0, maxFindings),
debugFindings: debugFindings.slice(0, maxFindings),
findings: findings.slice(0, maxFindings),
};
}
function findWorkspaceFilesByName(names: Set<string>, maxResults: number): string[] {
const results: string[] = [];
const walk = (currentPath: string) => {
if (results.length >= maxResults) return;
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
if (results.length >= maxResults) return;
if (entry.isDirectory()) {
if (reviewSkipDirectoryNames.has(entry.name)) continue;
walk(path.join(currentPath, entry.name));
continue;
}
if (entry.isFile() && names.has(entry.name)) {
results.push(toPosix(path.relative(workspaceRoot, path.join(currentPath, entry.name))));
}
}
};
walk(workspaceRoot);
return results;
}
function findRootGeneratedArtifacts(): string[] {
if (!existsSync(workspaceRoot)) return [];
return readdirSync(workspaceRoot, { withFileTypes: true })
.filter((entry) => rootGeneratedArtifactNames.has(entry.name))
.map((entry) => entry.name);
}
async function reviewProject(
projectKey: string,
input: Required<
Pick<GlobalCodeReviewInput, 'contentScanMode' | 'includeContentScan' | 'maxFindingsPerProject'>
>,
): Promise<ReviewProjectResult> {
const project = resolveProject(projectKey);
const repoType = detectRepoType(project.path);
const status =
repoType === 'git'
? await tryExecFile('git', ['status', '--short'], project.path)
: { ok: false, stderr: '', stdout: '' };
const changedFiles = status.ok ? parseGitStatusFiles(status.stdout) : [];
const trackedFiles = repoType === 'git' ? await listGitTrackedFiles(project.path) : [];
const trackedFileSet = new Set(trackedFiles);
const contentScannedFiles =
input.contentScanMode === 'all'
? trackedFiles
: changedFiles
.map((file) => file.replaceAll('\\', '/'))
.filter((file) => shouldReadReviewFile(file))
.filter((file) => trackedFileSet.has(file) || existsSync(path.join(project.path, file)));
const trackedSensitiveFiles = trackedFiles.filter(
(file) =>
sensitiveTrackedFilePattern.test(file) &&
!isAllowedSensitiveTrackedFile(project.alias, file),
);
const contentFindings = collectProjectContentFindings(
project,
contentScannedFiles,
input.includeContentScan,
input.maxFindingsPerProject,
);
const trackedSensitiveFindings = trackedSensitiveFiles
.slice(0, input.maxFindingsPerProject)
.map<ReviewFinding>((file) => ({
category: 'tracked-sensitive-file',
file,
level: 'P1',
message: '仓库仍跟踪真实环境或部署凭据类文件。',
project: project.relativePath,
suggestion: '使用 git rm --cached 移出索引,并提交 example 文件或文档说明。',
}));
return {
changedFiles,
contentScannedFiles,
credentialHints: contentFindings.credentialHints,
debugFindings: contentFindings.debugFindings,
findings: [...trackedSensitiveFindings, ...contentFindings.findings],
project,
repoType,
riskScan: await scanTaskRisk({
changedFiles,
project: projectKey,
}),
status: status.ok ? status.stdout : status.stderr,
trackedSensitiveFiles,
};
}
export async function buildGlobalCodeReview(
input: GlobalCodeReviewInput = {},
): Promise<Record<string, unknown>> {
const projects =
(input.projects?.length ?? 0) > 0
? input.projects!
: [...defaultReviewProjects];
const includeContentScan = input.includeContentScan ?? true;
const contentScanMode = input.contentScanMode ?? 'changed';
const maxFindingsPerProject = Math.max(
1,
Math.min(input.maxFindingsPerProject ?? 20, 200),
);
const projectResults = await Promise.all(
projects.map((project) =>
reviewProject(project, {
contentScanMode,
includeContentScan,
maxFindingsPerProject,
}),
),
);
const rootLegacyFiles =
input.includeRootScan === false
? []
: findWorkspaceFilesByName(new Set(['deploy.js', 'deploy.json']), 50);
const rootGeneratedArtifacts =
input.includeRootScan === false ? [] : findRootGeneratedArtifacts();
const taskRecordFindings =
input.includeRootScan === false
? []
: findTaskRecordGovernanceFindings(readProjectFileLines(workspaceRoot, 'TASKS.md'));
const findings = projectResults.flatMap((item) => [
...item.findings,
...item.debugFindings,
...item.credentialHints,
]);
const rootFindings = rootLegacyFiles.map<ReviewFinding>((file) => ({
category: 'legacy-deploy-file',
file,
level: 'P2',
message: '发现旧部署遗留文件。',
project: 'root',
suggestion: '确认是否仍被脚本引用;无用则删除并从 Git 索引移除。',
}));
const rootGeneratedFindings = rootGeneratedArtifacts.map<ReviewFinding>((file) => ({
category: 'root-generated-artifact',
file,
level: 'P2',
message: '根目录存在生成态临时产物。',
project: 'root',
suggestion: '统一迁入 .kt-workspace 对应子目录,并更新生成脚本或测试流程。',
}));
const allFindings = [
...findings,
...rootFindings,
...rootGeneratedFindings,
...taskRecordFindings,
];
return {
findings: allFindings,
generatedAt: new Date().toISOString(),
notes: [
'本工具只读扫描,不会修改文件、删除文件或提交代码。',
'P1 优先处理:冲突标记、被跟踪的真实 env/部署凭据文件。',
'默认 contentScanMode=changed只对当前变更文件做 P2/P3 内容扫描,并放过短中文显示标签,避免历史误报污染上下文;需要全仓库深扫时传 all。',
'P2 需要人工确认:疑似凭据字面量、旧部署文件、调试断点。',
'P3 为清洁度问题:运行时 console.log 等。',
],
projects: projectResults.map((item) => ({
changedFiles: item.changedFiles,
contentScannedFileCount: item.contentScannedFiles.length,
credentialHintCount: item.credentialHints.length,
debugFindingCount: item.debugFindings.length,
findingCount: item.findings.length,
project: item.project,
repoType: item.repoType,
riskScan: item.riskScan,
status: item.status,
trackedSensitiveFiles: item.trackedSensitiveFiles,
})),
summary: {
changedProjectCount: projectResults.filter((item) => item.changedFiles.length > 0)
.length,
contentScanMode,
findingCount: allFindings.length,
p1: allFindings.filter((item) => item.level === 'P1').length,
p2: allFindings.filter((item) => item.level === 'P2').length,
p3: allFindings.filter((item) => item.level === 'P3').length,
reviewedProjects: projectResults.length,
rootGeneratedArtifacts,
rootLegacyFiles,
},
workspaceRoot,
};
}