mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Contributors and coding agents need one local command that both reports and enforces the quality contract. This change turns the PR gate into the shared verification entrypoint, adds path-selected local lanes, tightens coverage accounting around changed lines, and documents the repair loop in contributor and agent-facing guidance. Constraint: Ordinary PR verification must stay non-live and runnable without provider credentials Constraint: Coverage policy updates in this commit require maintainer approval before push/merge Rejected: Keep quality guidance only in docs | agents need executable scripts and AGENTS.md instructions to follow the loop consistently Confidence: high Scope-risk: broad Directive: Do not bypass `bun run verify` for production changes; fix failed lanes and coverage reports instead of lowering thresholds Tested: bun run check:policy Tested: ALLOW_CLI_CORE_CHANGE=1 ALLOW_COVERAGE_BASELINE_CHANGE=1 bun run verify Not-tested: live provider baseline; no provider credentials were required for this non-live PR gate
479 lines
15 KiB
TypeScript
479 lines
15 KiB
TypeScript
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import { dirname, join } from 'node:path'
|
|
import { baselineCases } from './baseline/cases'
|
|
import { executeBaselineCase } from './baseline/execute'
|
|
import { executeDesktopSmoke } from './desktop-smoke/execute'
|
|
import { lanesForMode } from './modes'
|
|
import { executeProviderSmoke } from './provider-smoke/execute'
|
|
import { writeReport } from './reporter'
|
|
import type {
|
|
CoverageSuiteSummary,
|
|
ImpactSummary,
|
|
LaneCategory,
|
|
LaneDefinition,
|
|
LaneResult,
|
|
QualityGateOptions,
|
|
QualityGateReport,
|
|
ReportArtifact,
|
|
} from './types'
|
|
|
|
type LaneExecutor = (lane: LaneDefinition, options: QualityGateOptions) => Promise<LaneResult>
|
|
|
|
function nowId() {
|
|
return new Date().toISOString().replace(/[:.]/g, '-')
|
|
}
|
|
|
|
async function output(cmd: string[], cwd: string) {
|
|
const proc = Bun.spawn(cmd, {
|
|
cwd,
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
const stdout = await new Response(proc.stdout).text()
|
|
const stderr = await new Response(proc.stderr).text()
|
|
const code = await proc.exited
|
|
if (code !== 0) {
|
|
return null
|
|
}
|
|
return (stdout || stderr).trim()
|
|
}
|
|
|
|
function sanitizeId(value: string) {
|
|
return value.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
}
|
|
|
|
function matchesLaneSelector(lane: LaneDefinition, selector: string) {
|
|
const normalized = selector.trim()
|
|
if (!normalized) return false
|
|
if (normalized.endsWith('*')) {
|
|
return lane.id.startsWith(normalized.slice(0, -1))
|
|
}
|
|
return lane.id === normalized
|
|
}
|
|
|
|
function filterLanesForOptions(lanes: LaneDefinition[], options: QualityGateOptions) {
|
|
const only = options.onlyLaneSelectors?.filter(Boolean) ?? []
|
|
const skip = options.skipLaneSelectors?.filter(Boolean) ?? []
|
|
let selected = lanes
|
|
|
|
if (only.length > 0) {
|
|
selected = selected.filter((lane) => only.some((selector) => matchesLaneSelector(lane, selector)))
|
|
}
|
|
if (skip.length > 0) {
|
|
selected = selected.filter((lane) => !skip.some((selector) => matchesLaneSelector(lane, selector)))
|
|
}
|
|
if (selected.length === 0) {
|
|
throw new Error(`No quality gate lanes matched selectors. only=${only.join(',') || 'none'} skip=${skip.join(',') || 'none'}`)
|
|
}
|
|
|
|
return selected
|
|
}
|
|
|
|
async function pipeToLog(
|
|
stream: ReadableStream<Uint8Array> | null,
|
|
logPath: string,
|
|
write: (chunk: Buffer) => void,
|
|
) {
|
|
if (!stream) return
|
|
const reader = stream.getReader()
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
const chunk = Buffer.from(value)
|
|
appendFileSync(logPath, chunk)
|
|
write(chunk)
|
|
}
|
|
}
|
|
|
|
async function gitInfo(rootDir: string) {
|
|
const sha = await output(['git', 'rev-parse', '--short', 'HEAD'], rootDir)
|
|
const status = await output(['git', 'status', '--short'], rootDir)
|
|
return {
|
|
sha,
|
|
dirty: Boolean(status),
|
|
}
|
|
}
|
|
|
|
async function runCommandLane(lane: LaneDefinition, options: QualityGateOptions): Promise<LaneResult> {
|
|
const started = Date.now()
|
|
const command = lane.command ?? []
|
|
const artifactRoot = options.runOutputDir ?? join(options.rootDir, 'artifacts', 'quality-runs', options.runId ?? 'current')
|
|
const logPath = join(artifactRoot, 'logs', `${sanitizeId(lane.id)}.log`)
|
|
|
|
if (options.dryRun) {
|
|
mkdirSync(dirname(logPath), { recursive: true })
|
|
writeFileSync(logPath, `$ ${command.join(' ')}\n[quality-gate] skipped: dry run\n`)
|
|
return {
|
|
id: lane.id,
|
|
title: lane.title,
|
|
status: 'skipped',
|
|
command,
|
|
durationMs: Date.now() - started,
|
|
skipReason: 'dry run',
|
|
logPath,
|
|
}
|
|
}
|
|
|
|
mkdirSync(dirname(logPath), { recursive: true })
|
|
writeFileSync(logPath, `$ ${command.join(' ')}\n`)
|
|
|
|
if (options.mode === 'pr' && lane.impactRequiredCheck) {
|
|
const requiredChecks = readImpactRequiredChecks(options)
|
|
if (!requiredChecks) {
|
|
const error = `Impact report unavailable before ${lane.impactRequiredCheck}`
|
|
appendFileSync(logPath, `[quality-gate] failed: ${error}\n`)
|
|
return {
|
|
id: lane.id,
|
|
title: lane.title,
|
|
status: 'failed',
|
|
command,
|
|
durationMs: Date.now() - started,
|
|
error,
|
|
logPath,
|
|
}
|
|
}
|
|
|
|
const requiredCheck = normalizeImpactCheck(lane.impactRequiredCheck)
|
|
if (!requiredChecks.includes(requiredCheck)) {
|
|
const skipReason = `${requiredCheck} not required by impact report`
|
|
appendFileSync(logPath, `[quality-gate] skipped: ${skipReason}\n`)
|
|
return {
|
|
id: lane.id,
|
|
title: lane.title,
|
|
status: 'skipped',
|
|
command,
|
|
durationMs: Date.now() - started,
|
|
skipReason,
|
|
logPath,
|
|
}
|
|
}
|
|
}
|
|
|
|
const streamLogs = process.env.QUALITY_GATE_STREAM_LOGS === '1'
|
|
const writeStdout = streamLogs ? (chunk: Buffer) => process.stdout.write(chunk) : () => {}
|
|
const writeStderr = streamLogs ? (chunk: Buffer) => process.stderr.write(chunk) : () => {}
|
|
const proc = Bun.spawn(command, {
|
|
cwd: options.rootDir,
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
const [exitCode] = await Promise.all([
|
|
proc.exited,
|
|
pipeToLog(proc.stdout, logPath, writeStdout),
|
|
pipeToLog(proc.stderr, logPath, writeStderr),
|
|
])
|
|
|
|
return {
|
|
id: lane.id,
|
|
title: lane.title,
|
|
status: exitCode === 0 ? 'passed' : 'failed',
|
|
command,
|
|
durationMs: Date.now() - started,
|
|
exitCode,
|
|
logPath,
|
|
}
|
|
}
|
|
|
|
async function runBaselineCaseLane(lane: LaneDefinition, options: QualityGateOptions): Promise<LaneResult> {
|
|
const started = Date.now()
|
|
|
|
if (!options.allowLive) {
|
|
return {
|
|
id: lane.id,
|
|
title: lane.title,
|
|
status: 'skipped',
|
|
durationMs: Date.now() - started,
|
|
skipReason: 'live baseline cases require --allow-live',
|
|
}
|
|
}
|
|
|
|
const caseId = lane.baselineCaseId ?? lane.id.replace(/^baseline:/, '').split(':')[0]
|
|
const testCase = baselineCases.find((candidate) => candidate.id === caseId)
|
|
if (!testCase) {
|
|
return {
|
|
id: lane.id,
|
|
title: lane.title,
|
|
status: 'failed',
|
|
durationMs: Date.now() - started,
|
|
error: `Unknown baseline case: ${caseId}`,
|
|
}
|
|
}
|
|
|
|
const artifactRoot = options.runOutputDir ?? join(options.rootDir, 'artifacts', 'quality-runs', options.runId ?? 'current')
|
|
return executeBaselineCase(
|
|
testCase,
|
|
options.rootDir,
|
|
join(artifactRoot, 'cases', lane.id.replace(/[^a-zA-Z0-9._-]+/g, '-')),
|
|
lane.baselineTarget,
|
|
)
|
|
}
|
|
|
|
async function runLane(lane: LaneDefinition, options: QualityGateOptions): Promise<LaneResult> {
|
|
if (lane.kind === 'baseline-case') {
|
|
return runBaselineCaseLane(lane, options)
|
|
}
|
|
if (lane.kind === 'desktop-smoke') {
|
|
const started = Date.now()
|
|
|
|
if (!options.allowLive) {
|
|
return {
|
|
id: lane.id,
|
|
title: lane.title,
|
|
status: 'skipped',
|
|
durationMs: Date.now() - started,
|
|
skipReason: 'desktop agent-browser smoke requires --allow-live',
|
|
}
|
|
}
|
|
|
|
const artifactRoot = options.runOutputDir ?? join(options.rootDir, 'artifacts', 'quality-runs', options.runId ?? 'current')
|
|
return executeDesktopSmoke(
|
|
options.rootDir,
|
|
join(artifactRoot, 'cases', lane.id.replace(/[^a-zA-Z0-9._-]+/g, '-')),
|
|
lane.id,
|
|
lane.title,
|
|
lane.baselineTarget,
|
|
)
|
|
}
|
|
|
|
if (lane.kind === 'provider-smoke') {
|
|
const started = Date.now()
|
|
|
|
if (!options.allowLive) {
|
|
return {
|
|
id: lane.id,
|
|
title: lane.title,
|
|
status: 'skipped',
|
|
durationMs: Date.now() - started,
|
|
skipReason: 'provider smoke requires --allow-live',
|
|
}
|
|
}
|
|
|
|
const artifactRoot = options.runOutputDir ?? join(options.rootDir, 'artifacts', 'quality-runs', options.runId ?? 'current')
|
|
return executeProviderSmoke(
|
|
options.rootDir,
|
|
join(artifactRoot, 'cases', lane.id.replace(/[^a-zA-Z0-9._-]+/g, '-')),
|
|
lane.id,
|
|
lane.title,
|
|
lane.baselineTarget,
|
|
)
|
|
}
|
|
|
|
return runCommandLane(lane, options)
|
|
}
|
|
|
|
function summarize(results: LaneResult[]) {
|
|
return {
|
|
passed: results.filter((result) => result.status === 'passed').length,
|
|
failed: results.filter((result) => result.status === 'failed').length,
|
|
skipped: results.filter((result) => result.status === 'skipped').length,
|
|
}
|
|
}
|
|
|
|
function defaultCategoryForLane(lane: LaneDefinition): LaneCategory {
|
|
if (lane.category) return lane.category
|
|
if (lane.id === 'impact-report') return 'scope'
|
|
if (lane.id === 'coverage') return 'coverage'
|
|
if (lane.id === 'native-checks') return 'native'
|
|
if (lane.kind === 'baseline-case') return 'integration'
|
|
if (lane.kind === 'provider-smoke' || lane.kind === 'desktop-smoke') return 'smoke'
|
|
if (lane.id.includes('test') || lane.id.includes('checks')) return 'unit'
|
|
return 'governance'
|
|
}
|
|
|
|
function withLaneMetadata(lane: LaneDefinition, result: LaneResult): LaneResult {
|
|
return {
|
|
...result,
|
|
description: lane.description,
|
|
category: defaultCategoryForLane(lane),
|
|
live: Boolean(lane.live),
|
|
}
|
|
}
|
|
|
|
function readText(path: string | undefined) {
|
|
if (!path || !existsSync(path)) return null
|
|
return readFileSync(path, 'utf8')
|
|
}
|
|
|
|
function readSection(lines: string[], heading: string) {
|
|
const items: string[] = []
|
|
let active = false
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('## ')) {
|
|
active = line.trim() === `## ${heading}`
|
|
continue
|
|
}
|
|
|
|
if (!active) continue
|
|
if (line.startsWith('- ')) {
|
|
items.push(line.slice(2).trim())
|
|
}
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
function normalizeImpactCheck(value: string) {
|
|
return value.replace(/`/g, '').replace(/\s+/g, ' ').trim()
|
|
}
|
|
|
|
function impactReportLogPath(options: QualityGateOptions) {
|
|
const artifactRoot = options.runOutputDir ?? join(options.rootDir, 'artifacts', 'quality-runs', options.runId ?? 'current')
|
|
return join(artifactRoot, 'logs', 'impact-report.log')
|
|
}
|
|
|
|
function readImpactRequiredChecks(options: QualityGateOptions) {
|
|
const log = readText(impactReportLogPath(options))
|
|
if (!log) return null
|
|
return readSection(log.split(/\r?\n/), 'Required local checks').map(normalizeImpactCheck)
|
|
}
|
|
|
|
function splitSummaryList(value: string | undefined) {
|
|
if (!value || value === 'none') return []
|
|
return value.split(',').map((item) => item.trim()).filter(Boolean)
|
|
}
|
|
|
|
function parseImpactSummary(results: LaneResult[]): ImpactSummary | undefined {
|
|
const impact = results.find((result) => result.id === 'impact-report')
|
|
const log = readText(impact?.logPath)
|
|
if (!log) return undefined
|
|
|
|
const lines = log.split(/\r?\n/)
|
|
const findValue = (label: string) => {
|
|
const prefix = `${label}:`
|
|
return lines.find((line) => line.startsWith(prefix))?.slice(prefix.length).trim()
|
|
}
|
|
|
|
const changedFiles = Number(findValue('Changed files'))
|
|
|
|
return {
|
|
...(Number.isFinite(changedFiles) ? { changedFiles } : {}),
|
|
areas: splitSummaryList(findValue('Areas')),
|
|
labels: splitSummaryList(findValue('Labels')),
|
|
blocked: findValue('Blocked') === 'yes' ? true : findValue('Blocked') === 'no' ? false : undefined,
|
|
requiredChecks: readSection(lines, 'Required local checks'),
|
|
testCoverageSignals: readSection(lines, 'Test coverage signals'),
|
|
riskNotes: readSection(lines, 'Risk notes'),
|
|
}
|
|
}
|
|
|
|
function coverageReportPathFromLog(results: LaneResult[]) {
|
|
const coverage = results.find((result) => result.id === 'coverage')
|
|
const log = readText(coverage?.logPath)
|
|
if (!log) return null
|
|
return log.match(/Coverage report:\s*(.+coverage-report\.md)/)?.[1]?.trim() ?? null
|
|
}
|
|
|
|
function parseCoverageSummary(results: LaneResult[]) {
|
|
const reportPath = coverageReportPathFromLog(results)
|
|
if (!reportPath) return undefined
|
|
|
|
const jsonPath = reportPath.replace(/coverage-report\.md$/, 'coverage-report.json')
|
|
if (!existsSync(jsonPath)) return undefined
|
|
|
|
const parsed = JSON.parse(readFileSync(jsonPath, 'utf8')) as {
|
|
suites?: Array<CoverageSuiteSummary & {
|
|
summary?: Pick<CoverageSuiteSummary, 'lines' | 'functions' | 'branches' | 'statements'>
|
|
}>
|
|
failures?: string[]
|
|
}
|
|
|
|
return {
|
|
reportPath,
|
|
suites: (parsed.suites ?? []).map((suite) => ({
|
|
id: suite.id,
|
|
title: suite.title,
|
|
status: suite.status,
|
|
lines: suite.lines ?? suite.summary?.lines,
|
|
functions: suite.functions ?? suite.summary?.functions,
|
|
branches: suite.branches ?? suite.summary?.branches,
|
|
statements: suite.statements ?? suite.summary?.statements,
|
|
})),
|
|
failures: parsed.failures ?? [],
|
|
}
|
|
}
|
|
|
|
function collectReportArtifacts(outputDir: string, results: LaneResult[]): ReportArtifact[] {
|
|
const artifacts: ReportArtifact[] = [
|
|
{ title: 'Quality report markdown', path: join(outputDir, 'report.md') },
|
|
{ title: 'Quality report JSON', path: join(outputDir, 'report.json') },
|
|
{ title: 'Quality report JUnit', path: join(outputDir, 'junit.xml') },
|
|
]
|
|
|
|
const coveragePath = coverageReportPathFromLog(results)
|
|
if (coveragePath) {
|
|
artifacts.push({ title: 'Coverage report markdown', path: coveragePath })
|
|
artifacts.push({ title: 'Coverage report JSON', path: coveragePath.replace(/coverage-report\.md$/, 'coverage-report.json') })
|
|
}
|
|
|
|
return artifacts
|
|
}
|
|
|
|
function enforceReleaseLiveLanes(
|
|
options: QualityGateOptions,
|
|
lanes: LaneDefinition[],
|
|
results: LaneResult[],
|
|
) {
|
|
if (options.mode !== 'release' || options.dryRun) {
|
|
return results
|
|
}
|
|
|
|
return results.map((result, index) => {
|
|
if (result.status !== 'skipped' || !lanes[index]?.live) {
|
|
return result
|
|
}
|
|
|
|
return {
|
|
...result,
|
|
status: 'failed' as const,
|
|
error: result.skipReason ?? 'release live lane was skipped',
|
|
skipReason: undefined,
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function runQualityGate(options: QualityGateOptions) {
|
|
return runQualityGateLanes(options, lanesForMode(options.mode, options.baselineTargets))
|
|
}
|
|
|
|
export async function runQualityGateLanes(
|
|
options: QualityGateOptions,
|
|
lanes: LaneDefinition[],
|
|
executeLane: LaneExecutor = runLane,
|
|
) {
|
|
const runId = options.runId ?? nowId()
|
|
const startedAt = new Date().toISOString()
|
|
const artifactsRoot = options.artifactsDir ?? join(options.rootDir, 'artifacts', 'quality-runs')
|
|
const outputDir = join(artifactsRoot, runId)
|
|
mkdirSync(outputDir, { recursive: true })
|
|
const selectedLanes = filterLanesForOptions(lanes, options)
|
|
|
|
const runOptions = { ...options, runId, runOutputDir: outputDir }
|
|
const rawResults: LaneResult[] = []
|
|
for (const lane of selectedLanes) {
|
|
const result = await executeLane(lane, runOptions)
|
|
rawResults.push(withLaneMetadata(lane, result))
|
|
}
|
|
const results = enforceReleaseLiveLanes(options, selectedLanes, rawResults)
|
|
|
|
const report: QualityGateReport = {
|
|
schemaVersion: 1,
|
|
runId,
|
|
mode: options.mode,
|
|
dryRun: options.dryRun,
|
|
allowLive: options.allowLive,
|
|
startedAt,
|
|
finishedAt: new Date().toISOString(),
|
|
rootDir: options.rootDir,
|
|
git: await gitInfo(options.rootDir),
|
|
results,
|
|
impact: parseImpactSummary(results),
|
|
coverage: parseCoverageSummary(results),
|
|
artifacts: collectReportArtifacts(outputDir, results),
|
|
summary: summarize(results),
|
|
}
|
|
|
|
writeReport(report, outputDir)
|
|
return { report, outputDir }
|
|
}
|