import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' export const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) const normalize = (value) => value.replaceAll('\\', '/') const attribute = (source, name) => source.match(new RegExp(`\\b${name}=(?:"([^"]*)"|'([^']*)')`))?.slice(1).find((value) => value !== undefined) async function exists(file) { try { await stat(file) return true } catch (error) { if (error?.code === 'ENOENT') return false throw error } } async function javaSourceCount(moduleDirectory) { const root = path.join(moduleDirectory, 'src', 'main', 'java') if (!await exists(root)) return 0 let count = 0 async function walk(directory) { for (const entry of await readdir(directory, { withFileTypes: true })) { const target = path.join(directory, entry.name) if (entry.isDirectory()) await walk(target) else if (entry.isFile() && entry.name.endsWith('.java')) count += 1 } } await walk(root) return count } async function openApiFiles(moduleDirectory, module) { const root = path.join(moduleDirectory, 'src', 'main', 'resources', 'openapi') if (!await exists(root)) return [] const files = [] async function walk(directory) { for (const entry of await readdir(directory, { withFileTypes: true })) { const target = path.join(directory, entry.name) if (entry.isDirectory()) await walk(target) else if (entry.isFile() && /\.(?:json|ya?ml)$/i.test(entry.name)) { files.push(normalize(path.join(module, path.relative(moduleDirectory, target)))) } } } await walk(root) return files.sort() } export function parseModules(pom) { const block = pom.match(/([\s\S]*?)<\/modules>/)?.[1] ?? '' return [...block.matchAll(/([^<]+)<\/module>/g)].map((match) => match[1].trim()) } export function parseServices(serviceMap) { return [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map((match) => match[1]).sort() } export function parseJacocoReport(xml) { if (!//.test(xml)) throw new Error('not a JaCoCo report') const counters = [...xml.matchAll(/]*)\/?\s*>/g)] .map((match) => match[1]) .filter((source) => attribute(source, 'type') === 'LINE') if (counters.length === 0) throw new Error('JaCoCo LINE counter is missing') const summary = counters.at(-1) const missed = Number(attribute(summary, 'missed')) const covered = Number(attribute(summary, 'covered')) if (!Number.isInteger(missed) || !Number.isInteger(covered)) throw new Error('invalid JaCoCo LINE counter') const total = missed + covered return { missed, covered, percentage: total === 0 ? null : Number((covered * 100 / total).toFixed(2)) } } export function parseSpotbugsReport(xml) { if (!//.test(xml)) throw new Error('not a SpotBugs report') const findings = [...xml.matchAll(/]*)>/g)].map((match) => match[1]) const byPriority = {} const byCategory = {} for (const finding of findings) { const priority = attribute(finding, 'priority') ?? 'unknown' const category = attribute(finding, 'category') ?? 'unknown' byPriority[priority] = (byPriority[priority] ?? 0) + 1 byCategory[category] = (byCategory[category] ?? 0) + 1 } return { findings: findings.length, byPriority, byCategory } } async function reportState(file, parser, root) { const relative = normalize(path.relative(root, file)) if (!await exists(file)) return { status: 'missing', path: relative } try { return { status: 'valid', path: relative, ...parser(await readFile(file, 'utf8')) } } catch (error) { return { status: 'invalid', path: relative, error: error instanceof Error ? error.message : String(error) } } } export async function buildQualitySummary(root = repositoryRoot) { const modules = parseModules(await readFile(path.join(root, 'pom.xml'), 'utf8')) const services = parseServices((await readFile(path.join(root, 'deploy', 'service-map.yml'), 'utf8')).replaceAll('\r\n', '\n')) const moduleRows = [] const documents = [] for (const module of modules) { const moduleDirectory = path.join(root, module) const sources = await javaSourceCount(moduleDirectory) documents.push(...await openApiFiles(moduleDirectory, module)) if (sources === 0) continue const jacoco = await reportState(path.join(moduleDirectory, 'target', 'site', 'jacoco', 'jacoco.xml'), parseJacocoReport, root) const spotbugs = await reportState(path.join(moduleDirectory, 'target', 'spotbugsXml.xml'), parseSpotbugsReport, root) spotbugs.html = await exists(path.join(moduleDirectory, 'target', 'site', 'spotbugs.html')) moduleRows.push({ module, javaSources: sources, jacoco, spotbugs }) } const coveredServices = [...new Set(documents.map((file) => file.split('/')[0]))] .filter((module) => services.includes(module)).sort() const codeFirstServices = services.filter((service) => !coveredServices.includes(service)) const count = (kind, status) => moduleRows.filter((row) => row[kind].status === status).length return { topology: { modules: modules.length, codeModules: moduleRows.length, services: services.length }, openapi: { documents: documents.length, coveredServices, codeFirstServices, files: documents.sort() }, jacoco: { present: count('jacoco', 'valid'), invalid: count('jacoco', 'invalid'), missing: count('jacoco', 'missing') }, spotbugs: { present: count('spotbugs', 'valid'), invalid: count('spotbugs', 'invalid'), missing: count('spotbugs', 'missing'), findings: moduleRows.reduce((sum, row) => sum + (row.spotbugs.findings ?? 0), 0) }, modules: moduleRows } } export function requirementFailures(summary, options = {}) { const failures = [] for (const row of summary.modules) { if (row.jacoco.status === 'invalid') failures.push(`${row.module}: invalid JaCoCo report (${row.jacoco.error})`) if (row.spotbugs.status === 'invalid') failures.push(`${row.module}: invalid SpotBugs report (${row.spotbugs.error})`) if (row.spotbugs.status === 'valid' && !row.spotbugs.html) failures.push(`${row.module}: SpotBugs HTML report is missing`) if (options.requireJacoco && row.jacoco.status === 'missing') failures.push(`${row.module}: JaCoCo report is missing`) if (options.requireSpotbugs && row.spotbugs.status === 'missing') failures.push(`${row.module}: SpotBugs report is missing`) } return failures } function printSummary(summary) { process.stdout.write(`Java quality summary: ${summary.topology.modules} modules, ${summary.topology.codeModules} code modules, ${summary.topology.services} services.\n`) process.stdout.write(`OpenAPI: ${summary.openapi.documents} documents, ${summary.openapi.coveredServices.length}/${summary.topology.services} services; gaps: ${summary.openapi.codeFirstServices.join(', ')}.\n`) process.stdout.write(`JaCoCo: ${summary.jacoco.present} valid, ${summary.jacoco.missing} missing, ${summary.jacoco.invalid} invalid.\n`) process.stdout.write(`SpotBugs: ${summary.spotbugs.present} valid, ${summary.spotbugs.missing} missing, ${summary.spotbugs.invalid} invalid, ${summary.spotbugs.findings} findings.\n`) } async function main() { const args = process.argv.slice(2) const rootIndex = args.indexOf('--root') const outputIndex = args.indexOf('--output') const root = rootIndex >= 0 ? path.resolve(args[rootIndex + 1]) : repositoryRoot const summary = await buildQualitySummary(root) printSummary(summary) if (outputIndex >= 0) { const output = path.resolve(args[outputIndex + 1]) await mkdir(path.dirname(output), { recursive: true }) await writeFile(output, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') } const failures = requirementFailures(summary, { requireJacoco: args.includes('--require-jacoco'), requireSpotbugs: args.includes('--require-spotbugs') }) if (failures.length > 0) throw new Error(`Quality report contract failed:\n${failures.map((failure) => `- ${failure}`).join('\n')}`) } if (import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) process.exitCode = 1 }) }