mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
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:
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
Executable
+214
@@ -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)
|
||||
}
|
||||
}
|
||||
Executable
+73
@@ -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[@]}"
|
||||
Reference in New Issue
Block a user