getcert-backend / api /v2 /_services /githubValidationEngine.ts
ggop213's picture
fix: explicit ESM local import extensions to resolve Vercel Runtime ERR_MODULE_NOT_FOUND
d6596e1
Raw History Blame Contribute Delete
9.71 kB
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<T>(path: string): Promise<T> {
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<GitHubValidationReport> {
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<Array<{ commit: { message: string } }>>(
`/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<Record<string, number>>(`/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,
};
}