From 8cada04d10ccfda9dc9d1e44ab6ab80cc812cfee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 10 Jul 2026 20:37:43 +0800 Subject: [PATCH] fix(ci): align local verify with PR policy Fail PR and release quality runs when the impact policy blocks the diff, and accept root-runtime regression coverage across service and utility seams. Tested: bun run check:policy Confidence: high Scope-risk: narrow --- scripts/pr/change-policy.test.ts | 13 ++++++++ scripts/pr/change-policy.ts | 15 ++-------- scripts/quality-gate/runner.test.ts | 46 +++++++++++++++++++++++++++++ scripts/quality-gate/runner.ts | 30 ++++++++++++++++++- 4 files changed, 90 insertions(+), 14 deletions(-) diff --git a/scripts/pr/change-policy.test.ts b/scripts/pr/change-policy.test.ts index 845c3bd9..286ad65c 100644 --- a/scripts/pr/change-policy.test.ts +++ b/scripts/pr/change-policy.test.ts @@ -171,6 +171,19 @@ describe('evaluateChangePolicy', () => { expect(covered.missingTestSignals).toEqual([]) }) + test('accepts root runtime regression tests across service and utility seams', () => { + const result = evaluateChangePolicy( + [ + 'src/utils/messages.ts', + 'src/services/api/streamRetry.test.ts', + ], + ['allow-cli-core-change'], + ) + + expect(result.missingTestSignals).toEqual([]) + expect(result.blocked).toBe(false) + }) + test('blocks coverage baseline and threshold changes without maintainer override', () => { const result = evaluateChangePolicy([ 'scripts/quality-gate/coverage-baseline.json', diff --git a/scripts/pr/change-policy.ts b/scripts/pr/change-policy.ts index d5fc35fb..7973d478 100644 --- a/scripts/pr/change-policy.ts +++ b/scripts/pr/change-policy.ts @@ -249,15 +249,9 @@ function missingTestSignals(files: string[]) { const desktopProd = changedProductionFiles(files, (file) => file.startsWith('desktop/src/')) const serverProd = changedProductionFiles(files, (file) => file.startsWith('src/server/')) const adapterProd = changedProductionFiles(files, (file) => file.startsWith('adapters/')) - const agentRuntimeProd = changedProductionFiles(files, (file) => ( - file.startsWith('src/tools/') || - file.startsWith('src/utils/') - )) const rootRuntimeProd = changedProductionFiles(files, (file) => ( file.startsWith('src/') && - !file.startsWith('src/server/') && - !file.startsWith('src/tools/') && - !file.startsWith('src/utils/') + !file.startsWith('src/server/') )) if (desktopProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('desktop/src/'))) { @@ -269,14 +263,9 @@ function missingTestSignals(files: string[]) { if (adapterProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('adapters/'))) { signals.push('Adapter product files changed without an adapter test file in the PR.') } - if (agentRuntimeProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('src/tools/') || file.startsWith('src/utils/'))) { - signals.push('Agent/runtime product files changed without a tools/utils test file in the PR.') - } if (rootRuntimeProd.length > 0 && !hasMatchingTest(files, (file) => ( file.startsWith('src/') && - !file.startsWith('src/server/') && - !file.startsWith('src/tools/') && - !file.startsWith('src/utils/') + !file.startsWith('src/server/') ))) { signals.push('Root runtime product files changed without a matching root runtime test file in the PR.') } diff --git a/scripts/quality-gate/runner.test.ts b/scripts/quality-gate/runner.test.ts index 2c133344..b4dea1c3 100644 --- a/scripts/quality-gate/runner.test.ts +++ b/scripts/quality-gate/runner.test.ts @@ -433,6 +433,52 @@ describe('runQualityGate', () => { } }) + test('fails PR mode when the impact report is blocked by policy', async () => { + const artifactsDir = mkdtempSync(join(tmpdir(), 'quality-gate-test-')) + const lanes: LaneDefinition[] = [ + { + id: 'impact-report', + title: 'Impact report', + description: 'Writes a blocked policy decision', + kind: 'command', + command: ['bash', '-lc', [ + 'printf "%s\\n"', + '"# PR impact report"', + '""', + '"Changed files: 1"', + '"Areas: cli-core"', + '"Labels: none"', + '"Blocked: yes"', + '"Blocking reasons:"', + '"- CLI core changes require maintainer approval."', + '""', + '"## Required local checks"', + '"- bun run check:server"', + ].join(' ')], + requiredForModes: ['pr'], + }, + ] + + try { + const { report } = await runQualityGateLanes({ + mode: 'pr', + dryRun: false, + allowLive: false, + baselineTargets: [], + rootDir: process.cwd(), + artifactsDir, + runId: 'blocked-impact-test', + }, lanes) + + expect(report.impact?.blocked).toBe(true) + expect(report.results[0].status).toBe('failed') + expect(report.results[0].error).toContain('change policy blocked') + expect(report.summary.failed).toBe(1) + } finally { + rmSync(artifactsDir, { recursive: true, force: true }) + } + }) + test('release mode treats skipped live lanes as failures', async () => { const artifactsDir = mkdtempSync(join(tmpdir(), 'quality-gate-test-')) const lanes: LaneDefinition[] = [ diff --git a/scripts/quality-gate/runner.ts b/scripts/quality-gate/runner.ts index e921e8c5..0fa80505 100644 --- a/scripts/quality-gate/runner.ts +++ b/scripts/quality-gate/runner.ts @@ -432,6 +432,33 @@ function enforceReleaseLiveLanes( }) } +function enforceBlockedImpact( + options: QualityGateOptions, + results: LaneResult[], +) { + if (options.dryRun || (options.mode !== 'pr' && options.mode !== 'release')) { + return results + } + + const impact = parseImpactSummary(results) + if (!impact?.blocked) { + return results + } + + return results.map((result) => { + if (result.id !== 'impact-report' || result.status === 'failed') { + return result + } + + return { + ...result, + status: 'failed' as const, + exitCode: result.exitCode === 0 ? 1 : result.exitCode, + error: 'Local change policy blocked this diff; inspect the impact report and use maintainer overrides only when explicitly approved.', + } + }) +} + export async function runQualityGate(options: QualityGateOptions) { return runQualityGateLanes(options, lanesForMode(options.mode, options.baselineTargets)) } @@ -454,7 +481,8 @@ export async function runQualityGateLanes( const result = await executeLane(lane, runOptions) rawResults.push(withLaneMetadata(lane, result)) } - const results = enforceReleaseLiveLanes(options, selectedLanes, rawResults) + const releaseResults = enforceReleaseLiveLanes(options, selectedLanes, rawResults) + const results = enforceBlockedImpact(options, releaseResults) const report: QualityGateReport = { schemaVersion: 1,