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([ '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; 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; heading: string; line: number; } | null = null; const pushMissingFieldFindings = () => { if (!currentRecord) return; for (const field of taskRecordRequiredFields) { if (currentRecord.fields.has(field)) continue; findings.push({ category: 'tasks-record-missing-field', file: 'TASKS.md', line: currentRecord.line, level: 'P2', message: `TASKS 最近记录缺少「${field}」字段。`, project: 'root', suggestion: '最近记录只保留范围、关键词、验证三项,详细过程迁入 docs 或历史索引。', }); } }; for (let index = recentStart + 1; index < recentEnd; index += 1) { const line = lines[index].trim(); if (line.startsWith('### ')) { pushMissingFieldFindings(); currentRecord = { fields: new Set(), heading: line.replace(/^###\s+/, ''), line: index + 1, }; continue; } if (!currentRecord) continue; 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 { 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, 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 >, ): Promise { 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((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> { 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((file) => ({ category: 'legacy-deploy-file', file, level: 'P2', message: '发现旧部署遗留文件。', project: 'root', suggestion: '确认是否仍被脚本引用;无用则删除并从 Git 索引移除。', })); const rootGeneratedFindings = rootGeneratedArtifacts.map((file) => ({ category: 'root-generated-artifact', file, level: 'P2', message: '根目录存在生成态临时产物。', project: 'root', suggestion: '统一迁入 .kt-workspace 对应子目录,并更新生成脚本或测试流程。', })); 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, }; }