import type { CommitValidation, GitHubRepoMetadata, GitHubValidationReport, ReadmeValidation, StructureValidation, } from '../_types.js'; type ParsedRepo = { owner: string; repo: string }; const DEFAULT_MIN_COMMITS = Number(process.env.MIN_PROJECT_COMMITS || 8); const CODE_FILE_REGEX = /\.(ts|tsx|js|jsx|py|java|go|rs|php|rb|cs|kt|swift|sql|scala|sh|ipynb)$/i; const DOC_FILE_REGEX = /\.(md|txt|rst)$/i; function parseRepoUrl(repoUrl: string): ParsedRepo { const cleaned = repoUrl.trim().replace(/\.git$/, ''); const url = new URL(cleaned); const parts = url.pathname.split('/').filter(Boolean); if (parts.length < 2) { throw new Error('Invalid GitHub repository URL'); } return { owner: parts[0], repo: parts[1], }; } function githubHeaders(): HeadersInit { const token = process.env.GITHUB_TOKEN; const headers: HeadersInit = { Accept: 'application/vnd.github+json', 'User-Agent': 'GetCert-v2-validator', }; if (token) { headers.Authorization = `Bearer ${token}`; } return headers; } async function githubGet(path: string): Promise { const response = await fetch(`https://api.github.com${path}`, { headers: githubHeaders(), }); if (!response.ok) { const body = await response.text(); throw new Error(`GitHub API error ${response.status}: ${body}`); } return (await response.json()) as T; } function decodeBase64(content: string): string { return Buffer.from(content, 'base64').toString('utf8'); } function validateStructure(tree: Array<{ path: string; type?: string; size?: number }>): StructureValidation { const treePaths = tree.map((entry) => entry.path); const lower = treePaths.map((path) => path.toLowerCase()); const fileEntries = tree.filter((entry) => (entry.type ? entry.type === 'blob' : !entry.path.endsWith('/'))); const filePaths = fileEntries.map((entry) => entry.path.toLowerCase()); const frontendSignals = [ 'src/', 'public/', 'app/', 'pages/', 'components/', 'index.html', 'vite.config', 'next.config', 'angular.json', ]; const backendSignals = [ 'api/', 'server/', 'backend/', 'controllers/', 'routes/', 'main.py', 'app.py', 'manage.py', 'pom.xml', 'go.mod', 'dockerfile', 'vercel.json', ]; const hasFrontend = lower.some((path) => frontendSignals.some((signal) => path.includes(signal))); const hasBackend = lower.some((path) => backendSignals.some((signal) => path.includes(signal))); const hasPython = lower.some((path) => path.endsWith('.py')); const hasApiUsage = lower.some((path) => path.includes('routes') || path.includes('controller') || path.includes('endpoint') || path.includes('api/') || path.includes('graphql') || path.includes('openapi') ); const keySignals = lower .filter((path) => path.includes('auth') || path.includes('api') || path.includes('db') || path.includes('schema') || path.includes('model') || path.includes('migration') ) .slice(0, 30); const meaningfulFileCount = filePaths.filter((path) => { if (CODE_FILE_REGEX.test(path)) return true; if (path.endsWith('dockerfile')) return true; if (path.endsWith('package.json') || path.endsWith('pyproject.toml') || path.endsWith('requirements.txt')) return true; if (path.endsWith('tsconfig.json') || path.endsWith('vite.config.ts') || path.endsWith('vercel.json')) return true; return false; }).length; const docLikeCount = filePaths.filter((path) => DOC_FILE_REGEX.test(path) || path.includes('license')).length; const emptyCodeFileCount = fileEntries.filter((entry) => { const path = entry.path.toLowerCase(); const size = typeof entry.size === 'number' ? entry.size : 1; return CODE_FILE_REGEX.test(path) && size === 0; }).length; const suspiciousPatterns: string[] = []; if (fileEntries.length < 8) { suspiciousPatterns.push(`Repository has too few files (${fileEntries.length})`); } if (meaningfulFileCount < 3) { suspiciousPatterns.push('Repository has too few meaningful source/config files'); } if (docLikeCount >= Math.max(3, Math.floor(fileEntries.length * 0.7))) { suspiciousPatterns.push('Repository appears mostly documentation with little implementation'); } if (emptyCodeFileCount >= 3) { suspiciousPatterns.push('Repository contains multiple empty code files (possible fake scaffold)'); } if (!(hasFrontend || hasBackend || hasPython || hasApiUsage) && keySignals.length < 2) { suspiciousPatterns.push('No clear implementation architecture signals detected'); } const passed = suspiciousPatterns.length === 0; return { passed, hasFrontend, hasBackend, hasPython, hasApiUsage, fileCount: fileEntries.length, meaningfulFileCount, suspiciousPatterns, keySignals, }; } function validateCommits(commitMessages: string[], minRequired = DEFAULT_MIN_COMMITS): CommitValidation { const normalized = commitMessages .map((message) => message.toLowerCase().trim()) .filter(Boolean); const commitCount = normalized.length; const suspiciousPatterns: string[] = []; if (commitCount < minRequired) { suspiciousPatterns.push(`Too few commits (${commitCount})`); } const genericWords = ['update', 'fix', 'changes', 'wip', 'temp', 'misc', 'final']; const repeatedGeneric = normalized.filter((message) => genericWords.includes(message)).length; if (repeatedGeneric >= Math.max(3, Math.floor(commitCount * 0.45))) { suspiciousPatterns.push('Commit messages are excessively generic'); } const singleWordMessages = normalized.filter((message) => message.split(/\s+/).length === 1).length; if (singleWordMessages >= Math.max(4, Math.floor(commitCount * 0.5))) { suspiciousPatterns.push('High ratio of one-word commit messages detected'); } const uniqueCount = new Set(normalized).size; const uniqueRatio = commitCount > 0 ? uniqueCount / commitCount : 0; if (commitCount > 0 && uniqueRatio < 0.45) { suspiciousPatterns.push('High commit message repetition detected'); } const suspiciousBulkMarkers = normalized.filter((message) => message.includes('initial commit') || message.includes('upload project') || message.includes('final submission') ).length; if (commitCount <= 3 && suspiciousBulkMarkers > 0) { suspiciousPatterns.push('Possible bulk upload pattern detected'); } const repeatedGenericRatio = commitCount > 0 ? repeatedGeneric / commitCount : 1; const singleWordRatio = commitCount > 0 ? singleWordMessages / commitCount : 1; let score = 100; if (commitCount < minRequired) { score -= Math.min(45, (minRequired - commitCount) * 6); } score -= Math.round(repeatedGenericRatio * 25); score -= Math.round(singleWordRatio * 20); if (uniqueRatio < 0.5) { score -= Math.round((0.5 - uniqueRatio) * 40); } if (suspiciousBulkMarkers > 0) { score -= 15; } score = Math.max(0, Math.min(100, score)); const passed = commitCount >= minRequired && score >= 55; return { passed, score, commitCount, minRequired, suspiciousPatterns, }; } function validateReadme(readme: string): ReadmeValidation { const text = readme || ''; const lowered = text.toLowerCase(); const requiredSections = ['overview', 'setup', 'architecture', 'usage', 'proof']; const missingSections = requiredSections.filter((section) => !lowered.includes(section)); const richnessSignals = [ 'api', 'database', 'authentication', 'deployment', 'testing', 'architecture', 'trade-off', 'validation', 'error handling', ]; const richnessScore = richnessSignals.filter((signal) => lowered.includes(signal)).length; const hasMinimumLength = text.length >= 800; const passed = hasMinimumLength && missingSections.length <= 2 && richnessScore >= 4; return { passed, length: text.length, requiredSections, missingSections, richnessScore, }; } export async function validateGitHubRepository(repoUrl: string): Promise { const parsed = parseRepoUrl(repoUrl); const repoData = await githubGet<{ default_branch: string; language: string | null; stargazers_count: number; forks_count: number; }>(`/repos/${parsed.owner}/${parsed.repo}`); const treeData = await githubGet<{ tree: Array<{ path: string; type?: string; size?: number }> }>( `/repos/${parsed.owner}/${parsed.repo}/git/trees/${repoData.default_branch}?recursive=1` ); const commitsData = await githubGet>( `/repos/${parsed.owner}/${parsed.repo}/commits?per_page=100` ); const readmeData = await githubGet<{ content: string }>(`/repos/${parsed.owner}/${parsed.repo}/readme`); const languagesData = await githubGet>(`/repos/${parsed.owner}/${parsed.repo}/languages`); const metadata: GitHubRepoMetadata = { owner: parsed.owner, repo: parsed.repo, defaultBranch: repoData.default_branch, language: repoData.language, stars: repoData.stargazers_count, forks: repoData.forks_count, treePaths: treeData.tree.map((item) => item.path), commitMessages: commitsData.map((item) => item.commit.message), readme: decodeBase64(readmeData.content), languages: languagesData, }; const structure = validateStructure(treeData.tree); const commits = validateCommits(metadata.commitMessages); const readme = validateReadme(metadata.readme); return { metadata, structure, commits, readme, overallPassed: commits.passed && readme.passed && structure.passed, }; }