2026-07-11 20:06:36 +08:00

980 lines
30 KiB
TypeScript

#!/usr/bin/env bun
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, relative, sep, win32 } from 'node:path'
import { rootBunTestFilter } from '../pr/bun-test-filter'
import { createSandboxedTestEnvironment } from '../pr/test-environment'
import { loadQuarantineManifest, quarantinedPathSet } from './quarantine'
type CoverageMetric = {
total: number
covered: number
pct: number
}
type CoverageSummary = {
lines: CoverageMetric
functions: CoverageMetric
branches: CoverageMetric
statements: CoverageMetric
}
type CoverageScope = {
id: string
title: string
includePrefixes: string[]
excludePrefixes?: string[]
excludePathFragments?: string[]
excludeSuffixes?: string[]
}
type SuiteCoverage = {
id: string
title: string
status: 'passed' | 'failed'
command: string[]
durationMs: number
summary?: CoverageSummary
logPath: string
error?: string
note?: string
}
type CoverageThresholds = {
schemaVersion: 1
minimums: Record<string, Partial<Record<keyof CoverageSummary, number>>>
targets?: Record<string, Partial<Record<keyof CoverageSummary, number>>>
changedLines?: {
minimumPercent: number
}
ratchet?: {
baselinePath: string
allowedDropPercent: number
}
}
type BaselineFile = {
schemaVersion: 1
generatedAt?: string
suites: Record<string, CoverageSummary>
}
type CoverageReport = {
schemaVersion: 1
runId: string
startedAt: string
finishedAt: string
outputDir: string
baselineRef?: string
suites: SuiteCoverage[]
changedLines?: ChangedLineCoverage
targetGaps: string[]
failures: string[]
}
type LcovRecord = {
file: string
linesTotal: number
linesCovered: number
functionsTotal: number
functionsCovered: number
branchesTotal: number
branchesCovered: number
lineHits: Map<number, number>
}
type FileLineCoverage = {
suiteId: string
executableLines: Set<number>
coveredLines: Set<number>
}
type ChangedLineCoverage = {
minimumPercent: number
covered: number
total: number
pct: number
files: Array<{
file: string
suiteId: string
covered: number
total: number
pct: number
reason?: string
}>
failures: string[]
}
const ROOT_DIR = process.cwd()
const DEFAULT_THRESHOLDS_PATH = join(ROOT_DIR, 'scripts', 'quality-gate', 'coverage-thresholds.json')
const ROOT_COVERAGE_SCOPES: CoverageScope[] = [
{
id: 'server-api',
title: 'Server/API',
includePrefixes: ['src/server/'],
excludePathFragments: ['/__tests__/', '/fixtures/'],
excludeSuffixes: ['.test.ts', '.test.tsx'],
},
{
id: 'agent-tools',
title: 'Agent tools',
includePrefixes: ['src/tools/'],
excludePathFragments: ['/__tests__/', '/fixtures/'],
excludeSuffixes: ['.test.ts', '.test.tsx'],
},
{
id: 'agent-utils',
title: 'Agent utils',
includePrefixes: ['src/utils/'],
excludePathFragments: ['/__tests__/', '/fixtures/'],
excludeSuffixes: ['.test.ts', '.test.tsx'],
},
]
const ADAPTERS_SCOPE: CoverageScope = {
id: 'adapters',
title: 'IM adapters',
includePrefixes: ['adapters/'],
excludePrefixes: ['adapters/node_modules/'],
excludeSuffixes: ['.test.ts', '.test.tsx', '.d.ts'],
}
const DESKTOP_SCOPE: CoverageScope = {
id: 'desktop',
title: 'Desktop React',
includePrefixes: ['desktop/src/'],
excludePrefixes: [
'desktop/src/mocks/',
'desktop/src/types/',
],
excludeSuffixes: ['.test.ts', '.test.tsx', '.d.ts', 'vite-env.d.ts', '.css'],
}
const ROOT_RUNTIME_CHANGED_SCOPE: CoverageScope = {
id: 'root-runtime',
title: 'Root runtime',
includePrefixes: ['src/'],
excludePrefixes: ['src/server/', 'src/tools/', 'src/utils/'],
excludePathFragments: ['/__tests__/', '/fixtures/'],
excludeSuffixes: ['.test.ts', '.test.tsx', '.d.ts'],
}
const CHANGED_LINE_SCOPES = [
...ROOT_COVERAGE_SCOPES,
ROOT_RUNTIME_CHANGED_SCOPE,
ADAPTERS_SCOPE,
DESKTOP_SCOPE,
]
const TEST_FILE_PATTERN = /\.test\.[cm]?[jt]sx?$/
function nowId() {
return new Date().toISOString().replace(/[:.]/g, '-')
}
function parseArgs(argv: string[]) {
const args = new Map<string, string | boolean>()
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (!arg.startsWith('--')) continue
const next = argv[index + 1]
if (next && !next.startsWith('--')) {
args.set(arg, next)
index += 1
} else {
args.set(arg, true)
}
}
return args
}
function pct(covered: number, total: number) {
return total === 0 ? 100 : Number(((covered / total) * 100).toFixed(2))
}
function metric(covered: number, total: number): CoverageMetric {
return { covered, total, pct: pct(covered, total) }
}
function normalize(path: string, rootDir = ROOT_DIR) {
return relative(rootDir, path).split(sep).join('/')
}
function normalizeCoveragePath(path: string, rootDir = ROOT_DIR) {
const normalized = path.replace(/\\/g, '/')
if (/^[A-Za-z]:\//.test(normalized)) {
return win32.relative(rootDir.replace(/\//g, '\\'), normalized.replace(/\//g, '\\'))
.split(win32.sep)
.join('/')
}
if (normalized.startsWith('/')) {
return relative(rootDir, normalized).split(sep).join('/')
}
return normalized.replace(/^\.\//, '')
}
export function prefixRelativeLcovSourcePaths(content: string, prefix: string) {
const normalizedPrefix = prefix.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
return content.replace(/^SF:(.+)$/gm, (line, sourcePath: string) => {
const normalizedSourcePath = String(sourcePath).replace(/\\/g, '/').replace(/^\.\//, '')
if (
normalizedSourcePath.startsWith('/') ||
/^[A-Za-z]:\//.test(normalizedSourcePath) ||
normalizedSourcePath.startsWith(`${normalizedPrefix}/`)
) {
return line
}
return `SF:${normalizedPrefix}/${normalizedSourcePath}`
})
}
function matchesScope(file: string, scope: CoverageScope) {
const normalized = file.replace(/\\/g, '/')
if (!/\.[cm]?[jt]sx?$/.test(normalized)) {
return false
}
if (!scope.includePrefixes.some((prefix) => normalized.startsWith(prefix))) {
return false
}
if (scope.excludePrefixes?.some((prefix) => normalized.startsWith(prefix))) {
return false
}
if (scope.excludePathFragments?.some((fragment) => normalized.includes(fragment))) {
return false
}
if (scope.excludeSuffixes?.some((suffix) => normalized.endsWith(suffix))) {
return false
}
return true
}
function walkTestFiles(path: string, files: string[], excluded: Set<string>, rootDir = ROOT_DIR) {
const stat = statSync(path)
if (stat.isDirectory()) {
for (const entry of readdirSync(path)) {
walkTestFiles(join(path, entry), files, excluded, rootDir)
}
return
}
if (!stat.isFile()) return
const normalized = normalize(path, rootDir)
if (TEST_FILE_PATTERN.test(normalized) && !excluded.has(normalized)) {
files.push(normalized)
}
}
export function collectServerTestFiles(rootDir = ROOT_DIR, quarantineManifest = loadQuarantineManifest()) {
const excluded = quarantinedPathSet(quarantineManifest)
const files: string[] = []
walkTestFiles(join(rootDir, 'src'), files, excluded, rootDir)
return files.sort()
}
function parseLcovRecords(content: string, options: {
rootDir?: string
scope?: CoverageScope
} = {}) {
const records: LcovRecord[] = []
let current: LcovRecord | null = null
function flush() {
if (!current) return
if (!options.scope || matchesScope(current.file, options.scope)) {
records.push(current)
}
current = null
}
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (line === 'end_of_record') {
flush()
continue
}
if (line.startsWith('SF:')) {
flush()
current = {
file: normalizeCoveragePath(line.slice(3), options.rootDir),
linesTotal: 0,
linesCovered: 0,
functionsTotal: 0,
functionsCovered: 0,
branchesTotal: 0,
branchesCovered: 0,
lineHits: new Map(),
}
continue
}
if (!current) continue
if (line.startsWith('LF:')) current.linesTotal += Number(line.slice(3)) || 0
if (line.startsWith('LH:')) current.linesCovered += Number(line.slice(3)) || 0
if (line.startsWith('FNF:')) current.functionsTotal += Number(line.slice(4)) || 0
if (line.startsWith('FNH:')) current.functionsCovered += Number(line.slice(4)) || 0
if (line.startsWith('BRF:')) current.branchesTotal += Number(line.slice(4)) || 0
if (line.startsWith('BRH:')) current.branchesCovered += Number(line.slice(4)) || 0
if (line.startsWith('DA:')) {
const [lineNumber, hits] = line.slice(3).split(',')
const parsedLine = Number(lineNumber)
if (Number.isFinite(parsedLine)) {
current.lineHits.set(parsedLine, Number(hits) || 0)
}
}
}
flush()
return records
}
export function hasUsableLcov(content: string, options: {
rootDir?: string
scope?: CoverageScope
} = {}) {
return parseLcovRecords(content, options).some(isUsableLcovRecord)
}
function isUsableLcovRecord(record: LcovRecord) {
return record.linesTotal > 0 ||
record.functionsTotal > 0 ||
record.branchesTotal > 0 ||
record.lineHits.size > 0
}
export function parseBunTestFileCount(output: string) {
const match = output.match(/Ran \d+ tests? across (\d+) files?\./)
return match ? Number(match[1]) : null
}
export function buildRootCoverageCommand(outputDir: string, serverFiles: string[]) {
return [
'bun',
'--no-env-file',
'--feature=TRANSCRIPT_CLASSIFIER',
'test',
'--timeout=20000',
'--coverage',
'--coverage-reporter=lcov',
'--coverage-reporter=text',
'--coverage-dir',
join(outputDir, 'root-server'),
...serverFiles.map(rootBunTestFilter),
]
}
function summarizeLcovRecords(records: LcovRecord[]): CoverageSummary {
let linesTotal = 0
let linesCovered = 0
let functionsTotal = 0
let functionsCovered = 0
let branchesTotal = 0
let branchesCovered = 0
for (const record of records) {
linesTotal += record.linesTotal
linesCovered += record.linesCovered
functionsTotal += record.functionsTotal
functionsCovered += record.functionsCovered
branchesTotal += record.branchesTotal
branchesCovered += record.branchesCovered
}
return {
lines: metric(linesCovered, linesTotal),
functions: metric(functionsCovered, functionsTotal),
branches: metric(branchesCovered, branchesTotal),
statements: metric(linesCovered, linesTotal),
}
}
export function parseLcov(content: string, options: {
rootDir?: string
scope?: CoverageScope
} = {}): CoverageSummary {
return summarizeLcovRecords(parseLcovRecords(content, options))
}
function lcovRecordLineCoverage(records: LcovRecord[], suiteId: string) {
const coverage = new Map<string, FileLineCoverage>()
for (const record of records) {
const executableLines = new Set<number>()
const coveredLines = new Set<number>()
for (const [line, hits] of record.lineHits) {
executableLines.add(line)
if (hits > 0) {
coveredLines.add(line)
}
}
coverage.set(record.file, { suiteId, executableLines, coveredLines })
}
return coverage
}
function lcovLineCoverage(content: string, suiteId: string, scope: CoverageScope, rootDir = ROOT_DIR) {
return lcovRecordLineCoverage(
parseLcovRecords(content, { rootDir, scope }),
suiteId,
)
}
function parseVitestSummary(path: string): CoverageSummary {
const raw = JSON.parse(readFileSync(path, 'utf8')) as {
total: Record<string, { total: number; covered: number; pct: number }>
}
const total = raw.total
return {
lines: metric(total.lines.covered, total.lines.total),
functions: metric(total.functions.covered, total.functions.total),
branches: metric(total.branches.covered, total.branches.total),
statements: metric(total.statements.covered, total.statements.total),
}
}
export function hasUsableCoverageSummary(summary: CoverageSummary) {
return Object.values(summary).some((coverage) => coverage.total > 0)
}
async function runCommand(command: string[], cwd: string, logPath: string) {
const started = Date.now()
const sandboxHome = mkdtempSync(join(tmpdir(), 'cc-haha-coverage-test-'))
try {
const proc = Bun.spawn(command, {
cwd,
env: createSandboxedTestEnvironment(sandboxHome),
stdout: 'pipe',
stderr: 'pipe',
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
mkdirSync(dirname(logPath), { recursive: true })
writeFileSync(logPath, `$ ${command.join(' ')}\n${stdout}${stderr}`)
return {
exitCode,
durationMs: Date.now() - started,
output: `${stdout}${stderr}`,
}
} finally {
rmSync(sandboxHome, { recursive: true, force: true })
}
}
async function runSuite(
id: string,
title: string,
command: string[],
cwd: string,
suiteDir: string,
readSummary: () => CoverageSummary,
): Promise<SuiteCoverage> {
mkdirSync(suiteDir, { recursive: true })
const logPath = join(suiteDir, 'coverage.log')
const result = await runCommand(command, cwd, logPath)
if (result.exitCode !== 0) {
return {
id,
title,
status: 'failed',
command,
durationMs: result.durationMs,
logPath,
error: `coverage command exited with ${result.exitCode}`,
}
}
try {
const summary = readSummary()
if (!hasUsableCoverageSummary(summary)) {
throw new Error('coverage artifact contained no measurable source records')
}
return {
id,
title,
status: 'passed',
command,
durationMs: result.durationMs,
logPath,
summary,
}
} catch (error) {
return {
id,
title,
status: 'failed',
command,
durationMs: result.durationMs,
logPath,
error: error instanceof Error ? error.message : String(error),
}
}
}
function loadThresholds(path = DEFAULT_THRESHOLDS_PATH): CoverageThresholds {
if (!existsSync(path)) {
return { schemaVersion: 1, minimums: {} }
}
return JSON.parse(readFileSync(path, 'utf8')) as CoverageThresholds
}
function readGitFile(rootDir: string, ref: string, filePath: string) {
const gitPath = filePath.startsWith('/')
? relative(rootDir, filePath).split(sep).join('/')
: filePath.replace(/\\/g, '/').replace(/^\.\//, '')
const proc = Bun.spawnSync(['git', 'show', `${ref}:${gitPath}`], {
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
})
if (proc.exitCode !== 0) {
return null
}
return new TextDecoder().decode(proc.stdout)
}
export function parseChangedLinesFromDiff(diff: string) {
const changed = new Map<string, Set<number>>()
let currentFile: string | null = null
let nextLine = 0
for (const rawLine of diff.split(/\r?\n/)) {
if (rawLine.startsWith('+++ ')) {
const file = rawLine.slice(4).trim()
currentFile = file === '/dev/null' ? null : file.replace(/^b\//, '')
continue
}
const hunk = rawLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
if (hunk) {
nextLine = Number(hunk[1])
continue
}
if (!currentFile || nextLine === 0) continue
if (rawLine.startsWith('+++')) continue
if (rawLine.startsWith('+')) {
let lines = changed.get(currentFile)
if (!lines) {
lines = new Set()
changed.set(currentFile, lines)
}
lines.add(nextLine)
nextLine += 1
continue
}
if (rawLine.startsWith('-')) continue
if (rawLine.length > 0) {
nextLine += 1
}
}
return changed
}
function gitOutput(rootDir: string, args: string[]) {
const proc = Bun.spawnSync(['git', ...args], {
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
})
if (proc.exitCode !== 0) return null
return new TextDecoder().decode(proc.stdout)
}
function collectChangedLines(rootDir: string, baseRef?: string) {
const explicitBase = baseRef?.trim()
if (explicitBase) {
const diff = gitOutput(rootDir, ['diff', '--unified=0', '--no-ext-diff', `${explicitBase}...HEAD`, '--'])
return diff ? parseChangedLinesFromDiff(diff) : new Map<string, Set<number>>()
}
const dirty = gitOutput(rootDir, ['diff', '--name-only', '--'])
if (dirty?.trim()) {
const diff = gitOutput(rootDir, ['diff', '--unified=0', '--no-ext-diff', 'HEAD', '--'])
return diff ? parseChangedLinesFromDiff(diff) : new Map<string, Set<number>>()
}
const branch = gitOutput(rootDir, ['branch', '--show-current'])?.trim()
const hasOriginMain = gitOutput(rootDir, ['rev-parse', '--verify', 'origin/main'])
if (branch && branch !== 'main' && hasOriginMain) {
const diff = gitOutput(rootDir, ['diff', '--unified=0', '--no-ext-diff', 'origin/main...HEAD', '--'])
return diff ? parseChangedLinesFromDiff(diff) : new Map<string, Set<number>>()
}
return new Map<string, Set<number>>()
}
export function evaluateChangedLineCoverage(
changedLines: Map<string, Set<number>>,
coverageByFile: Map<string, FileLineCoverage>,
scopes: CoverageScope[],
minimumPercent: number,
): ChangedLineCoverage {
const files: ChangedLineCoverage['files'] = []
let total = 0
let covered = 0
for (const [file, lines] of [...changedLines.entries()].sort(([a], [b]) => a.localeCompare(b))) {
const scope = scopes.find((candidate) => matchesScope(file, candidate))
if (!scope) continue
const fileCoverage = coverageByFile.get(file)
if (!fileCoverage) {
const lineCount = lines.size
total += lineCount
files.push({
file,
suiteId: scope.id,
covered: 0,
total: lineCount,
pct: 0,
reason: 'no coverage data for changed source file',
})
continue
}
const executableChangedLines = [...lines].filter((line) => fileCoverage.executableLines.has(line))
if (executableChangedLines.length === 0) continue
const fileCovered = executableChangedLines.filter((line) => fileCoverage.coveredLines.has(line)).length
total += executableChangedLines.length
covered += fileCovered
files.push({
file,
suiteId: fileCoverage.suiteId,
covered: fileCovered,
total: executableChangedLines.length,
pct: pct(fileCovered, executableChangedLines.length),
})
}
const coverage = pct(covered, total)
const failures = total > 0 && coverage + Number.EPSILON < minimumPercent
? [`changed-lines: coverage ${coverage}% is below minimum ${minimumPercent}%`]
: []
return {
minimumPercent,
covered,
total,
pct: coverage,
files,
failures,
}
}
function loadBaseline(path: string, rootDir = ROOT_DIR, baselineRef?: string): BaselineFile | null {
if (baselineRef) {
const raw = readGitFile(rootDir, baselineRef, path)
return raw ? JSON.parse(raw) as BaselineFile : null
}
const resolved = path.startsWith('/') ? path : join(rootDir, path)
if (!existsSync(resolved)) return null
return JSON.parse(readFileSync(resolved, 'utf8')) as BaselineFile
}
export function evaluateThresholds(
suites: SuiteCoverage[],
thresholds: CoverageThresholds,
rootDir = ROOT_DIR,
baselineRef?: string,
) {
const failures: string[] = []
const baseline = thresholds.ratchet?.baselinePath
? loadBaseline(thresholds.ratchet.baselinePath, rootDir, baselineRef)
: null
const allowedDrop = thresholds.ratchet?.allowedDropPercent ?? 0
for (const suite of suites) {
if (suite.status !== 'passed' || !suite.summary) {
failures.push(`${suite.id}: ${suite.error ?? 'coverage suite failed'}`)
continue
}
const minimums = thresholds.minimums[suite.id] ?? {}
for (const [metricName, minimum] of Object.entries(minimums)) {
const actual = suite.summary[metricName as keyof CoverageSummary].pct
if (actual + Number.EPSILON < minimum) {
failures.push(`${suite.id}: ${metricName} coverage ${actual}% is below minimum ${minimum}%`)
}
}
const baselineSummary = baseline?.suites[suite.id]
if (!baselineSummary) continue
for (const metricName of ['lines', 'functions', 'branches', 'statements'] as const) {
const actual = suite.summary[metricName].pct
const expected = baselineSummary[metricName].pct - allowedDrop
if (actual + Number.EPSILON < expected) {
failures.push(`${suite.id}: ${metricName} coverage ${actual}% dropped below baseline ${baselineSummary[metricName].pct}%`)
}
}
}
return failures
}
function evaluateTargetGaps(suites: SuiteCoverage[], thresholds: CoverageThresholds) {
const gaps: string[] = []
for (const suite of suites) {
if (suite.status !== 'passed' || !suite.summary) continue
const targets = thresholds.targets?.[suite.id] ?? {}
for (const [metricName, target] of Object.entries(targets)) {
const actual = suite.summary[metricName as keyof CoverageSummary].pct
if (actual + Number.EPSILON < target) {
gaps.push(`${suite.id}: ${metricName} coverage ${actual}% is below target ${target}%`)
}
}
}
return gaps
}
function renderReport(report: CoverageReport) {
const lines = [
'# Coverage Report',
'',
`- Run: ${report.runId}`,
`- Started: ${report.startedAt}`,
`- Finished: ${report.finishedAt}`,
`- Output: ${report.outputDir}`,
...(report.baselineRef ? [`- Baseline ref: ${report.baselineRef}`] : []),
'',
'| Suite | Status | Lines | Functions | Branches | Statements |',
'| --- | --- | ---: | ---: | ---: | ---: |',
]
for (const suite of report.suites) {
const summary = suite.summary
lines.push(`| ${[
suite.title,
suite.status,
summary ? `${summary.lines.pct}%` : '-',
summary ? `${summary.functions.pct}%` : '-',
summary ? `${summary.branches.pct}%` : '-',
summary ? `${summary.statements.pct}%` : '-',
].join(' | ')} |`)
}
const suiteNotes = report.suites.filter((suite) => suite.note)
if (suiteNotes.length > 0) {
lines.push('', '## Suite Notes', '')
for (const suite of suiteNotes) {
lines.push(`- ${suite.title}: ${suite.note}`)
}
}
lines.push('', '## Changed Lines', '')
if (!report.changedLines) {
lines.push('- not evaluated')
} else if (report.changedLines.total === 0) {
lines.push(`- No changed executable production lines matched the coverage scopes. Minimum: ${report.changedLines.minimumPercent}%`)
} else {
lines.push(
`- Coverage: ${report.changedLines.pct}% (${report.changedLines.covered}/${report.changedLines.total})`,
`- Minimum: ${report.changedLines.minimumPercent}%`,
'',
'| File | Suite | Lines | Reason |',
'| --- | --- | ---: | --- |',
)
for (const file of report.changedLines.files) {
lines.push(`| ${file.file} | ${file.suiteId} | ${file.pct}% (${file.covered}/${file.total}) | ${file.reason ?? '-'} |`)
}
}
lines.push('', '## Target Gaps', '')
if (report.targetGaps.length === 0) {
lines.push('- none')
} else {
for (const gap of report.targetGaps) {
lines.push(`- ${gap}`)
}
}
lines.push('', '## Failures', '')
if (report.failures.length === 0) {
lines.push('- none')
} else {
for (const failure of report.failures) {
lines.push(`- ${failure}`)
}
}
return lines.join('\n') + '\n'
}
export async function runCoverageGate(options: {
rootDir?: string
artifactsDir?: string
runId?: string
thresholdsPath?: string
baselineRef?: string
changedBaseRef?: string
} = {}) {
const rootDir = options.rootDir ?? ROOT_DIR
const runId = options.runId ?? nowId()
const outputDir = join(options.artifactsDir ?? join(rootDir, 'artifacts', 'coverage'), runId)
const startedAt = new Date().toISOString()
const baselineRef = options.baselineRef ?? process.env.COVERAGE_BASE_REF
mkdirSync(outputDir, { recursive: true })
const serverFiles = collectServerTestFiles(rootDir)
const suites: SuiteCoverage[] = []
const coverageByFile = new Map<string, FileLineCoverage>()
mkdirSync(join(outputDir, 'root-server'), { recursive: true })
const rootCommand = buildRootCoverageCommand(outputDir, serverFiles)
const rootLogPath = join(outputDir, 'root-server', 'coverage.log')
const rootResult = await runCommand(rootCommand, rootDir, rootLogPath)
const rootLcovPath = join(outputDir, 'root-server', 'lcov.info')
const rootLcov = existsSync(rootLcovPath)
? readFileSync(rootLcovPath, 'utf8')
: ''
const rootRecords = parseLcovRecords(rootLcov, { rootDir }).filter(isUsableLcovRecord)
const rootTestFileCount = parseBunTestFileCount(rootResult.output)
const rootTestDiscoveryComplete = rootTestFileCount === serverFiles.length
const rootCoverageAvailable = hasUsableLcov(rootLcov, { rootDir }) && rootTestDiscoveryComplete
if (rootResult.exitCode !== 0 && rootCoverageAvailable) {
writeFileSync(
rootLogPath,
`${readFileSync(rootLogPath, 'utf8')}\n# Coverage note: test assertions exited with ${rootResult.exitCode}; correctness is enforced by check:server's per-file sandboxed test processes. This lane uses the complete single-process LCOV universe because Bun LCOV does not expose function identities for lossless cross-process merging.\n`,
)
}
for (const scope of ROOT_COVERAGE_SCOPES) {
const scopedRecords = rootRecords.filter((record) => matchesScope(record.file, scope))
const scopeCoverageAvailable = rootCoverageAvailable && scopedRecords.length > 0
const summary = scopeCoverageAvailable
? summarizeLcovRecords(scopedRecords)
: undefined
suites.push({
id: scope.id,
title: scope.title,
status: scopeCoverageAvailable ? 'passed' : 'failed',
command: rootCommand,
durationMs: rootResult.durationMs,
logPath: rootLogPath,
...(summary ? { summary } : {}),
...(rootResult.exitCode !== 0 && rootCoverageAvailable ? {
note: `instrumentation test process exited with ${rootResult.exitCode}; correctness is enforced by the required per-file server gate`,
} : {}),
...(!scopeCoverageAvailable ? {
error: rootCoverageAvailable
? `coverage command produced no LCOV records for ${scope.id}`
: !rootTestDiscoveryComplete
? `coverage command discovered ${rootTestFileCount ?? 0}/${serverFiles.length} root test files`
: `coverage command exited with ${rootResult.exitCode} and produced no usable LCOV records`,
} : {}),
})
if (scopeCoverageAvailable) {
for (const [file, coverage] of lcovRecordLineCoverage(scopedRecords, scope.id)) {
coverageByFile.set(file, coverage)
}
}
}
const rootRuntimeRecords = rootRecords.filter((record) => (
matchesScope(record.file, ROOT_RUNTIME_CHANGED_SCOPE)
))
for (const [file, coverage] of lcovRecordLineCoverage(
rootRuntimeRecords,
ROOT_RUNTIME_CHANGED_SCOPE.id,
)) {
coverageByFile.set(file, coverage)
}
const adapters = await runSuite(
'adapters',
'IM adapters',
['bun', '--no-env-file', 'test', '--coverage', '--coverage-reporter=lcov', '--coverage-reporter=text', '--coverage-dir', join(outputDir, 'adapters')],
join(rootDir, 'adapters'),
join(outputDir, 'adapters'),
() => parseLcov(readFileSync(join(outputDir, 'adapters', 'lcov.info'), 'utf8')),
)
suites.push(adapters)
const adaptersLcovPath = join(outputDir, 'adapters', 'lcov.info')
if (adapters.status === 'passed' && existsSync(adaptersLcovPath)) {
const adaptersLcov = prefixRelativeLcovSourcePaths(readFileSync(adaptersLcovPath, 'utf8'), 'adapters')
for (const [file, coverage] of lcovLineCoverage(adaptersLcov, 'adapters', ADAPTERS_SCOPE, rootDir)) {
coverageByFile.set(file, coverage)
}
}
const desktop = await runSuite(
'desktop',
'Desktop React',
[
'bun',
'--no-env-file',
'run',
'test',
'--',
'--run',
'--coverage',
'--coverage.reporter=json-summary',
'--coverage.reporter=lcov',
`--coverage.reportsDirectory=${join(outputDir, 'desktop')}`,
'--testTimeout=20000',
],
join(rootDir, 'desktop'),
join(outputDir, 'desktop'),
() => parseVitestSummary(join(outputDir, 'desktop', 'coverage-summary.json')),
)
suites.push(desktop)
const desktopLcovPath = join(outputDir, 'desktop', 'lcov.info')
if (desktop.status === 'passed' && existsSync(desktopLcovPath)) {
const desktopLcov = prefixRelativeLcovSourcePaths(readFileSync(desktopLcovPath, 'utf8'), 'desktop')
for (const [file, coverage] of lcovLineCoverage(desktopLcov, 'desktop', DESKTOP_SCOPE, rootDir)) {
coverageByFile.set(file, coverage)
}
}
const thresholds = loadThresholds(options.thresholdsPath ?? join(rootDir, 'scripts', 'quality-gate', 'coverage-thresholds.json'))
const failures = evaluateThresholds(suites, thresholds, rootDir, baselineRef)
const changedLineMinimum = thresholds.changedLines?.minimumPercent
const changedLines = typeof changedLineMinimum === 'number'
? evaluateChangedLineCoverage(
collectChangedLines(rootDir, options.changedBaseRef ?? process.env.COVERAGE_BASE_REF),
coverageByFile,
CHANGED_LINE_SCOPES,
changedLineMinimum,
)
: undefined
if (changedLines) {
failures.push(...changedLines.failures)
}
const targetGaps = evaluateTargetGaps(suites, thresholds)
const report: CoverageReport = {
schemaVersion: 1,
runId,
startedAt,
finishedAt: new Date().toISOString(),
outputDir,
...(baselineRef ? { baselineRef } : {}),
suites,
...(changedLines ? { changedLines } : {}),
targetGaps,
failures,
}
writeFileSync(join(outputDir, 'coverage-report.json'), JSON.stringify(report, null, 2) + '\n')
writeFileSync(join(outputDir, 'coverage-report.md'), renderReport(report))
return report
}
if (import.meta.main) {
const args = parseArgs(process.argv.slice(2))
const report = await runCoverageGate({
artifactsDir: typeof args.get('--artifacts-dir') === 'string' ? String(args.get('--artifacts-dir')) : undefined,
runId: typeof args.get('--run-id') === 'string' ? String(args.get('--run-id')) : undefined,
thresholdsPath: typeof args.get('--thresholds') === 'string' ? String(args.get('--thresholds')) : undefined,
baselineRef: typeof args.get('--baseline-ref') === 'string' ? String(args.get('--baseline-ref')) : undefined,
changedBaseRef: typeof args.get('--changed-base') === 'string' ? String(args.get('--changed-base')) : undefined,
})
console.log(`Coverage report: ${report.outputDir}/coverage-report.md`)
console.log(`Summary: passed=${report.suites.filter((suite) => suite.status === 'passed').length} failed=${report.failures.length}`)
if (report.failures.length > 0) {
process.exit(1)
}
}