feat: make PR quality verification self-enforcing

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
This commit is contained in:
程序员阿江(Relakkes)
2026-05-06 22:33:43 +08:00
parent 9719726cd2
commit b156be8d8d
45 changed files with 2594 additions and 251 deletions
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, test } from 'bun:test'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { installPrePushHook } from './install'
function runGit(rootDir: string, args: string[]) {
const proc = Bun.spawnSync(['git', ...args], {
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
})
if (proc.exitCode !== 0) {
throw new Error(new TextDecoder().decode(proc.stderr))
}
return new TextDecoder().decode(proc.stdout).trim()
}
describe('installPrePushHook', () => {
test('copies the tracked hook and makes it executable', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'git-hook-install-test-'))
try {
const sourcePath = join(tempDir, 'source-pre-push')
const hookPath = join(tempDir, 'hooks', 'pre-push')
writeFileSync(sourcePath, '#!/usr/bin/env bash\necho quality\n')
const result = installPrePushHook({
rootDir: tempDir,
sourcePath,
hookPath,
})
expect(result.hookPath).toBe(hookPath)
expect(result.liveConfigured).toBe(false)
expect(readFileSync(hookPath, 'utf8')).toContain('echo quality')
expect(statSync(hookPath).mode & 0o111).toBeGreaterThan(0)
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
test('refuses to overwrite an unrelated existing hook unless forced', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'git-hook-install-test-'))
try {
const sourcePath = join(tempDir, 'source-pre-push')
const hookPath = join(tempDir, 'hooks', 'pre-push')
writeFileSync(sourcePath, '#!/usr/bin/env bash\necho new\n')
mkdirSync(join(tempDir, 'hooks'), { recursive: true })
writeFileSync(hookPath, '#!/usr/bin/env bash\necho old\n')
expect(() => installPrePushHook({
rootDir: tempDir,
sourcePath,
hookPath,
})).toThrow('Refusing to overwrite existing hook')
installPrePushHook({
rootDir: tempDir,
sourcePath,
hookPath,
force: true,
})
expect(readFileSync(hookPath, 'utf8')).toContain('echo new')
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
test('stores live gate settings in local git config', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'git-hook-install-test-'))
try {
runGit(tempDir, ['init'])
const sourcePath = join(tempDir, 'source-pre-push')
writeFileSync(sourcePath, '#!/usr/bin/env bash\necho live\n')
const result = installPrePushHook({
rootDir: tempDir,
sourcePath,
liveProviderModels: ['codingplan:main:codingplan-main'],
liveMode: 'baseline',
allowCliCoreChange: true,
allowCoverageBaselineChange: true,
})
expect(result.liveConfigured).toBe(true)
expect(readFileSync(result.hookPath, 'utf8')).toContain('echo live')
expect(runGit(tempDir, ['config', '--local', '--get', 'quality.prePushLive'])).toBe('true')
expect(runGit(tempDir, ['config', '--local', '--get', 'quality.prePushProviderModels'])).toBe('codingplan:main:codingplan-main')
expect(runGit(tempDir, ['config', '--local', '--get', 'quality.prePushLiveMode'])).toBe('baseline')
expect(runGit(tempDir, ['config', '--local', '--get', 'quality.allowCliCoreChange'])).toBe('true')
expect(runGit(tempDir, ['config', '--local', '--get', 'quality.allowCoverageBaselineChange'])).toBe('true')
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
})
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env bun
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
type LiveMode = 'smoke' | 'baseline'
export type InstallPrePushHookOptions = {
rootDir?: string
sourcePath?: string
hookPath?: string
force?: boolean
allowCliCoreChange?: boolean
allowCoverageBaselineChange?: boolean
allowMissingTests?: boolean
liveProviderModels?: string[]
liveMode?: LiveMode
live?: boolean
}
export type InstallPrePushHookResult = {
hookPath: string
liveConfigured: boolean
}
function decode(buffer: ArrayBuffer | Uint8Array) {
return new TextDecoder().decode(buffer).trim()
}
function runGit(rootDir: string, args: string[]) {
const proc = Bun.spawnSync(['git', ...args], {
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
})
if (proc.exitCode !== 0) {
throw new Error(decode(proc.stderr) || decode(proc.stdout) || `git ${args.join(' ')} failed`)
}
return decode(proc.stdout)
}
function gitHookPath(rootDir: string) {
return resolve(rootDir, runGit(rootDir, ['rev-parse', '--git-path', 'hooks/pre-push']))
}
function gitConfig(rootDir: string, key: string, value: string) {
runGit(rootDir, ['config', '--local', key, value])
}
function sameFileContent(leftPath: string, rightPath: string) {
return existsSync(leftPath) && readFileSync(leftPath, 'utf8') === readFileSync(rightPath, 'utf8')
}
export function installPrePushHook(options: InstallPrePushHookOptions = {}): InstallPrePushHookResult {
const rootDir = options.rootDir ? resolve(options.rootDir) : process.cwd()
const sourcePath = options.sourcePath ? resolve(options.sourcePath) : resolve(rootDir, 'scripts/git-hooks/pre-push')
const hookPath = options.hookPath ? resolve(options.hookPath) : gitHookPath(rootDir)
if (!existsSync(sourcePath)) {
throw new Error(`Pre-push hook source not found: ${sourcePath}`)
}
if (existsSync(hookPath) && !options.force && !sameFileContent(hookPath, sourcePath)) {
throw new Error(`Refusing to overwrite existing hook at ${hookPath}. Re-run with --force after reviewing it.`)
}
mkdirSync(dirname(hookPath), { recursive: true })
copyFileSync(sourcePath, hookPath)
chmodSync(hookPath, 0o755)
if (options.allowCliCoreChange) {
gitConfig(rootDir, 'quality.allowCliCoreChange', 'true')
}
if (options.allowCoverageBaselineChange) {
gitConfig(rootDir, 'quality.allowCoverageBaselineChange', 'true')
}
if (options.allowMissingTests) {
gitConfig(rootDir, 'quality.allowMissingTests', 'true')
}
if (options.live === false) {
gitConfig(rootDir, 'quality.prePushLive', 'false')
}
if (options.liveProviderModels && options.liveProviderModels.length > 0) {
gitConfig(rootDir, 'quality.prePushLive', 'true')
gitConfig(rootDir, 'quality.prePushProviderModels', options.liveProviderModels.join(' '))
}
if (options.liveMode) {
gitConfig(rootDir, 'quality.prePushLiveMode', options.liveMode)
}
return {
hookPath,
liveConfigured: Boolean(options.liveProviderModels?.length || options.liveMode || options.live === false),
}
}
type ParsedArgs = {
force: boolean
allowCliCoreChange: boolean
allowCoverageBaselineChange: boolean
allowMissingTests: boolean
liveProviderModels: string[]
liveMode?: LiveMode
live?: boolean
help: boolean
}
function parseArgs(argv: string[]): ParsedArgs {
const parsed: ParsedArgs = {
force: false,
allowCliCoreChange: false,
allowCoverageBaselineChange: false,
allowMissingTests: false,
liveProviderModels: [],
help: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const next = argv[index + 1]
if (arg === '--force') {
parsed.force = true
} else if (arg === '--allow-cli-core-change') {
parsed.allowCliCoreChange = true
} else if (arg === '--allow-coverage-baseline-change') {
parsed.allowCoverageBaselineChange = true
} else if (arg === '--allow-missing-tests') {
parsed.allowMissingTests = true
} else if (arg === '--no-live') {
parsed.live = false
} else if (arg === '--live-provider-model') {
if (!next || next.startsWith('--')) {
throw new Error('--live-provider-model requires a provider:model[:label] value')
}
parsed.liveProviderModels.push(next)
index += 1
} else if (arg === '--live-mode') {
if (next !== 'smoke' && next !== 'baseline') {
throw new Error('--live-mode must be smoke or baseline')
}
parsed.liveMode = next
index += 1
} else if (arg === '--help' || arg === '-h') {
parsed.help = true
} else {
throw new Error(`Unknown option: ${arg}`)
}
}
return parsed
}
function printHelp() {
console.log(`Install the repository pre-push quality gate.
Usage:
bun run hooks:install [-- --force] [-- --live-provider-model <selector>] [-- --live-mode smoke|baseline]
bun run hooks:install -- --allow-cli-core-change --allow-coverage-baseline-change
Examples:
bun run hooks:install
bun run quality:providers
bun run hooks:install -- --live-provider-model codingplan:main:codingplan-main
bun run hooks:install -- --live-provider-model codingplan:main:codingplan-main --live-mode baseline
bun run hooks:install -- --allow-cli-core-change --allow-coverage-baseline-change
`)
}
if (import.meta.main) {
try {
const args = parseArgs(process.argv.slice(2))
if (args.help) {
printHelp()
process.exit(0)
}
const result = installPrePushHook({
force: args.force,
allowCliCoreChange: args.allowCliCoreChange,
allowCoverageBaselineChange: args.allowCoverageBaselineChange,
allowMissingTests: args.allowMissingTests,
liveProviderModels: args.liveProviderModels,
liveMode: args.liveMode,
live: args.live,
})
console.log(`Installed pre-push quality gate: ${result.hookPath}`)
console.log('Every git push now runs: bun run quality:pr')
if (args.liveProviderModels.length > 0) {
console.log(`Live ${args.liveMode ?? 'smoke'} gate is enabled for ${args.liveProviderModels.length} provider selector(s).`)
} else if (args.live === false) {
console.log('Live model gate is disabled in local git config.')
} else {
console.log('Live model gate is disabled. Enable it with --live-provider-model after running bun run quality:providers.')
}
if (args.allowCliCoreChange || args.allowCoverageBaselineChange || args.allowMissingTests) {
console.log('Local maintainer override config was updated for this clone.')
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
git_config() {
git config --local --get "$1" 2>/dev/null || true
}
is_enabled() {
case "${1:-}" in
1|true|TRUE|yes|YES|on|ON)
return 0
;;
*)
return 1
;;
esac
}
echo "[pre-push] Running PR quality gate: bun run quality:pr"
if is_enabled "$(git_config quality.allowCliCoreChange)"; then
export ALLOW_CLI_CORE_CHANGE=1
fi
if is_enabled "$(git_config quality.allowMissingTests)"; then
export ALLOW_MISSING_TESTS=1
fi
if is_enabled "$(git_config quality.allowCoverageBaselineChange)"; then
export ALLOW_COVERAGE_BASELINE_CHANGE=1
fi
bun run quality:pr
live="${QUALITY_PRE_PUSH_LIVE:-$(git_config quality.prePushLive)}"
if ! is_enabled "$live"; then
echo "[pre-push] Live model smoke is disabled. Configure it with: bun run hooks:install -- --live-provider-model <selector>"
exit 0
fi
provider_models="${QUALITY_PRE_PUSH_PROVIDER_MODELS:-${QUALITY_PRE_PUSH_PROVIDER_MODEL:-$(git_config quality.prePushProviderModels)}}"
if [[ -z "${provider_models// }" ]]; then
echo "[pre-push] Live model smoke is enabled, but no provider selector is configured."
echo "[pre-push] Run: bun run quality:providers"
echo "[pre-push] Then: bun run hooks:install -- --live-provider-model <selector>"
exit 1
fi
live_mode="${QUALITY_PRE_PUSH_LIVE_MODE:-$(git_config quality.prePushLiveMode)}"
live_mode="${live_mode:-smoke}"
case "$live_mode" in
smoke)
cmd=(bun run quality:smoke)
;;
baseline)
cmd=(bun run quality:gate --mode baseline --allow-live)
;;
*)
echo "[pre-push] Unsupported live mode: $live_mode"
echo "[pre-push] Use smoke or baseline."
exit 1
;;
esac
for selector in $provider_models; do
cmd+=(--provider-model "$selector")
done
echo "[pre-push] Running live $live_mode gate with configured provider selector(s)."
"${cmd[@]}"