123 lines
5.0 KiB
JavaScript
123 lines
5.0 KiB
JavaScript
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||
export const repositoryRoot = resolve(scriptDir, '..')
|
||
|
||
function filesBelow(root, predicate) {
|
||
if (!statSafe(root)?.isDirectory()) return []
|
||
const result = []
|
||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||
if (entry.name === 'target' || entry.name === '.git') continue
|
||
const path = join(root, entry.name)
|
||
if (entry.isDirectory()) result.push(...filesBelow(path, predicate))
|
||
else if (predicate(path)) result.push(path)
|
||
}
|
||
return result
|
||
}
|
||
|
||
function statSafe(path) {
|
||
try { return statSync(path) } catch { return null }
|
||
}
|
||
|
||
function packageName(source) {
|
||
return source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || ''
|
||
}
|
||
|
||
function mapperInterfaces(javaFiles) {
|
||
return javaFiles.flatMap((path) => {
|
||
const source = readFileSync(path, 'utf8')
|
||
const names = [...source.matchAll(/\b(?:public\s+)?interface\s+(\w*Mapper)\b/g)].map((match) => match[1])
|
||
return names.map((name) => ({ path, source, packageName: packageName(source), name }))
|
||
})
|
||
}
|
||
|
||
function mapperScanAnnotation(source) {
|
||
const start = source.indexOf('@MapperScan')
|
||
if (start < 0) return ''
|
||
const end = source.indexOf('\n', start)
|
||
if (source[start + '@MapperScan'.length] !== '(') return source.slice(start, end < 0 ? undefined : end)
|
||
let depth = 0
|
||
for (let index = start + '@MapperScan'.length; index < source.length; index += 1) {
|
||
if (source[index] === '(') depth += 1
|
||
else if (source[index] === ')' && --depth === 0) return source.slice(start, index + 1)
|
||
}
|
||
return source.slice(start)
|
||
}
|
||
|
||
function moduleNames(root) {
|
||
const pom = readFileSync(join(root, 'pom.xml'), 'utf8')
|
||
return [...pom.matchAll(/<module>([^<]+)<\/module>/g)].map((match) => match[1].trim())
|
||
}
|
||
|
||
export function auditMapperScanning(root = repositoryRoot) {
|
||
const failures = []
|
||
let mapperCount = 0
|
||
let persistenceModuleCount = 0
|
||
|
||
for (const moduleName of moduleNames(root)) {
|
||
const moduleRoot = join(root, moduleName)
|
||
const javaRoot = join(moduleRoot, 'src', 'main', 'java')
|
||
const javaFiles = filesBelow(javaRoot, (path) => path.endsWith('.java'))
|
||
const mappers = mapperInterfaces(javaFiles)
|
||
if (!mappers.length) continue
|
||
|
||
persistenceModuleCount += 1
|
||
mapperCount += mappers.length
|
||
const mapperNames = new Map()
|
||
for (const mapper of mappers) {
|
||
const previous = mapperNames.get(mapper.name)
|
||
if (previous) {
|
||
failures.push(`${moduleName}: 根包扫描下 Mapper Bean 名称重复 ${mapper.name}(${relative(root, previous)}、${relative(root, mapper.path)})`)
|
||
} else mapperNames.set(mapper.name, mapper.path)
|
||
if (!/@Mapper\b/.test(mapper.source)) {
|
||
failures.push(`${moduleName}: ${mapper.packageName}.${mapper.name} 缺少 @Mapper(${relative(root, mapper.path)})`)
|
||
}
|
||
}
|
||
|
||
const applications = javaFiles.filter((path) => /@SpringBootApplication\b/.test(readFileSync(path, 'utf8')))
|
||
if (applications.length !== 1) {
|
||
failures.push(`${moduleName}: 期望且只能有一个 @SpringBootApplication,实际 ${applications.length} 个`)
|
||
continue
|
||
}
|
||
const applicationPath = applications[0]
|
||
const applicationSource = readFileSync(applicationPath, 'utf8')
|
||
const rootPackage = packageName(applicationSource)
|
||
const scan = mapperScanAnnotation(applicationSource)
|
||
if (!scan) failures.push(`${moduleName}: 启动类缺少统一 @MapperScan`)
|
||
else {
|
||
if (!scan.includes(`"${rootPackage}"`)) failures.push(`${moduleName}: @MapperScan 必须覆盖服务根包 ${rootPackage}`)
|
||
if (!/annotationClass\s*=\s*Mapper\.class/.test(scan)) failures.push(`${moduleName}: @MapperScan 必须使用 annotationClass = Mapper.class`)
|
||
}
|
||
|
||
for (const path of javaFiles) {
|
||
if (path === applicationPath) continue
|
||
if (/@MapperScan\b/.test(readFileSync(path, 'utf8'))) {
|
||
failures.push(`${moduleName}: 禁止分散维护 MapperScan(${relative(root, path)})`)
|
||
}
|
||
}
|
||
|
||
for (const mapper of mappers) {
|
||
if (!(mapper.packageName === rootPackage || mapper.packageName.startsWith(`${rootPackage}.`))) {
|
||
failures.push(`${moduleName}: ${mapper.packageName}.${mapper.name} 不在服务扫描根包 ${rootPackage} 内`)
|
||
}
|
||
}
|
||
}
|
||
|
||
return { failures, mapperCount, persistenceModuleCount }
|
||
}
|
||
|
||
function main() {
|
||
const result = auditMapperScanning(repositoryRoot)
|
||
if (result.failures.length) {
|
||
console.error(`Mapper 扫描合同失败,共 ${result.failures.length} 项:`)
|
||
result.failures.forEach((failure) => console.error(`- ${failure}`))
|
||
process.exitCode = 1
|
||
return
|
||
}
|
||
console.log(`Mapper 扫描合同通过:${result.persistenceModuleCount} 个持久化模块、${result.mapperCount} 个 Mapper 均使用 @Mapper,并由服务根包注解过滤扫描。`)
|
||
}
|
||
|
||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
|