532 lines
17 KiB
TypeScript
532 lines
17 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 { 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: '把额外说明迁入 docs,TASKS 最近记录只保留范围、关键词、验证。',
|
||
});
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|