Files
multica/scripts/check-tests.ts
Jiayuan b10b3ff709 Add AI code safety defense system: tests, pre-commit hooks, and CI
Implemented three-layer defense mechanism to prevent AI-generated code from breaking the codebase:
- Layer 1: AGENTS.md with universal AI agent instructions
- Layer 2: Husky pre-commit hooks with test file checking
- Layer 3: GitHub Actions CI with TypeScript, ESLint, tests, and coverage

Includes design document, test infrastructure, and coverage thresholds.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 00:22:33 +08:00

128 lines
3.9 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Pre-commit hook script that checks if staged source files have corresponding test files.
* This is an advisory check - it warns but does not block commits.
*/
import { execSync } from 'child_process'
import * as fs from 'fs'
import * as path from 'path'
// ANSI color codes
const colors = {
yellow: '\x1b[33m',
green: '\x1b[32m',
red: '\x1b[31m',
reset: '\x1b[0m',
bold: '\x1b[1m'
}
// Source directories that require tests
const SOURCE_DIRS = ['src/main', 'src/shared']
// Mapping from source paths to test paths
function getExpectedTestPath(sourcePath: string): string | null {
// src/main/conductor/Conductor.ts -> tests/unit/main/conductor/Conductor.test.ts
// src/shared/types.ts -> tests/unit/shared/types.test.ts
for (const sourceDir of SOURCE_DIRS) {
if (sourcePath.startsWith(sourceDir)) {
const relativePath = sourcePath.slice(sourceDir.length + 1) // +1 for the slash
const dirName = sourceDir.split('/')[1] // 'main' or 'shared'
const testPath = `tests/unit/${dirName}/${relativePath.replace(/\.ts$/, '.test.ts')}`
return testPath
}
}
return null
}
// Get list of staged files
function getStagedFiles(): string[] {
try {
const output = execSync('git diff --cached --name-only --diff-filter=ACM', {
encoding: 'utf-8'
})
return output.trim().split('\n').filter(Boolean)
} catch {
return []
}
}
// Check if a file is a TypeScript source file (not a test, not a type definition)
function isSourceFile(filePath: string): boolean {
if (!filePath.endsWith('.ts') && !filePath.endsWith('.tsx')) {
return false
}
if (filePath.includes('.test.') || filePath.includes('.spec.')) {
return false
}
if (filePath.endsWith('.d.ts')) {
return false
}
return SOURCE_DIRS.some((dir) => filePath.startsWith(dir))
}
// Check if a file is a test file
function isTestFile(filePath: string): boolean {
return filePath.includes('.test.') || filePath.includes('.spec.')
}
// Main function
function main(): void {
const stagedFiles = getStagedFiles()
if (stagedFiles.length === 0) {
console.log(`${colors.green}No staged files to check.${colors.reset}`)
return
}
const sourceFiles = stagedFiles.filter(isSourceFile)
const testFiles = stagedFiles.filter(isTestFile)
if (sourceFiles.length === 0) {
console.log(`${colors.green}No source files modified.${colors.reset}`)
return
}
// Check which source files are missing corresponding test files
const missingTests: { source: string; expectedTest: string }[] = []
for (const sourceFile of sourceFiles) {
const expectedTestPath = getExpectedTestPath(sourceFile)
if (!expectedTestPath) continue
// Check if the test file is staged or already exists
const testFileStaged = testFiles.some(
(tf) =>
tf === expectedTestPath ||
tf.includes(path.basename(sourceFile).replace(/\.tsx?$/, '.test.'))
)
const testFileExists = fs.existsSync(expectedTestPath)
if (!testFileStaged && !testFileExists) {
missingTests.push({ source: sourceFile, expectedTest: expectedTestPath })
}
}
if (missingTests.length > 0) {
console.log(
`\n${colors.yellow}${colors.bold}Warning: Source files modified without corresponding tests${colors.reset}\n`
)
console.log(' Modified source files:')
for (const { source } of missingTests) {
console.log(` ${colors.yellow}- ${source}${colors.reset}`)
}
console.log('\n Expected test files (not found):')
for (const { expectedTest } of missingTests) {
console.log(` ${colors.yellow}- ${expectedTest}${colors.reset}`)
}
console.log(`\n ${colors.bold}Consider adding tests for your changes.${colors.reset}`)
console.log(` Run 'pnpm test:run' to verify existing tests still pass.\n`)
} else {
console.log(`${colors.green}All modified source files have corresponding tests.${colors.reset}`)
}
}
main()