mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
Avoid Bun filter-mode repository scans that exhaust macOS file descriptors and corrupt subprocess test evidence. Apply rooted filters across server, contract, coverage, persistence, policy, desktop native, and adapter test entrypoints. Confidence: high Scope-risk: narrow Tested: bun run check:policy; bun run check:server; bun run check:chat-contract
964 lines
30 KiB
TypeScript
964 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
|
|
}
|
|
|
|
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 = ['bun', '--no-env-file', 'test', '--timeout=20000', '--coverage', '--coverage-reporter=lcov', '--coverage-reporter=text', '--coverage-dir', join(outputDir, 'root-server'), ...serverFiles.map(rootBunTestFilter)]
|
|
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)
|
|
}
|
|
}
|