basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, vi, beforeEach } from 'vitest';8import { assignFindingIds, runPlanApprovalGate } from './planApprovalGate.js';9import type { GateAgentResult, EvidenceBundle } from './types.js';10import { type PlanGateState, createPlanGateState } from './state.js';11import type { Config } from '../config/config.js';12 13// ── assignFindingIds unit tests ───────────────────────────────────────14 15describe('assignFindingIds', () => {16 it('should assign stable GF-N ids in order', () => {17 const result: GateAgentResult = {18 agent: 'plan_reviewer',19 decision: 'blocked',20 findings: [21 {22 localId: 'GF-1',23 severity: 'P2',24 issue: 'Missing feature X',25 rationale: 'not addressed',26 },27 {28 localId: 'GF-2',29 severity: 'P3',30 issue: 'Wrong file path',31 rationale: 'file moved',32 },33 ],34 };35 const merged = assignFindingIds(result);36 expect(merged).toHaveLength(2);37 expect(merged[0]!.id).toBe('GF-1');38 expect(merged[1]!.id).toBe('GF-2');39 });40 41 it('should return empty array when agent passes', () => {42 const result: GateAgentResult = {43 agent: 'plan_reviewer',44 decision: 'pass',45 findings: [],46 };47 const merged = assignFindingIds(result);48 expect(merged).toHaveLength(0);49 });50 51 it('should preserve all finding fields', () => {52 const result: GateAgentResult = {53 agent: 'plan_reviewer',54 decision: 'blocked',55 findings: [56 {57 localId: 'GF-1',58 severity: 'P1',59 issue: 'Critical',60 rationale: 'violates request',61 suggestedFix: 'Fix it',62 suggestedQuestion: 'Are you sure?',63 },64 ],65 };66 const merged = assignFindingIds(result);67 expect(merged[0]).toEqual({68 id: 'GF-1',69 severity: 'P1',70 issue: 'Critical',71 rationale: 'violates request',72 suggestedFix: 'Fix it',73 suggestedQuestion: 'Are you sure?',74 });75 });76});77 78// ── runPlanApprovalGate ───────────────────────────────────────────────79 80vi.mock('./gateReviewAgents.js', () => ({81 runGateAgent: vi.fn(),82}));83 84import { runGateAgent } from './gateReviewAgents.js';85 86const mockRunGateAgent = vi.mocked(runGateAgent);87 88function makeResult(overrides: Partial<GateAgentResult> = {}): GateAgentResult {89 return {90 agent: 'plan_reviewer',91 decision: 'pass',92 findings: [],93 ...overrides,94 };95}96 97describe('runPlanApprovalGate', () => {98 let gateState: PlanGateState;99 let mockConfig: Config;100 const signal = new AbortController().signal;101 102 const bundle: EvidenceBundle = {103 originalRequest: 'Add a button',104 plan: 'Step 1: add button component',105 };106 107 beforeEach(() => {108 gateState = createPlanGateState(1);109 mockConfig = {110 getPlanGateState: vi.fn(() => gateState),111 getSubagentManager: vi.fn(),112 } as unknown as Config;113 mockRunGateAgent.mockReset();114 });115 116 it('should return unavailable when no gate state', async () => {117 (mockConfig.getPlanGateState as ReturnType<typeof vi.fn>).mockReturnValue(118 undefined,119 );120 121 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);122 expect(decision.kind).toBe('unavailable');123 });124 125 it('should return approved when agent passes with no findings', async () => {126 mockRunGateAgent.mockResolvedValue(makeResult({ decision: 'pass' }));127 128 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);129 expect(decision.kind).toBe('approved');130 });131 132 it('should return unavailable when agent reports itself as unavailable (even with empty findings)', async () => {133 mockRunGateAgent.mockResolvedValue(134 makeResult({ decision: 'unavailable', findings: [] }),135 );136 137 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);138 expect(decision.kind).toBe('unavailable');139 expect((decision as { reason: string }).reason).toContain('unavailable');140 });141 142 it('should return blocked when agent has P1 findings', async () => {143 mockRunGateAgent.mockResolvedValue(144 makeResult({145 decision: 'blocked',146 findings: [147 {148 localId: 'GF-1',149 severity: 'P1',150 issue: 'Critical flaw',151 rationale: 'violates request',152 },153 ],154 }),155 );156 157 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);158 expect(decision.kind).toBe('blocked');159 });160 161 it('should return needs_user when agent returns needs_user with suggestedQuestion', async () => {162 mockRunGateAgent.mockResolvedValue(163 makeResult({164 decision: 'needs_user',165 findings: [166 {167 localId: 'GF-1',168 severity: 'P2',169 issue: 'Ambiguous scope',170 rationale: 'unclear',171 suggestedQuestion: 'Do you want feature A or B?',172 },173 ],174 }),175 );176 177 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);178 expect(decision.kind).toBe('needs_user');179 expect((decision as { questions: string[] }).questions).toEqual([180 'Do you want feature A or B?',181 ]);182 });183 184 it('should fall through to blocked when needs_user has no suggestedQuestion', async () => {185 mockRunGateAgent.mockResolvedValue(186 makeResult({187 decision: 'needs_user',188 findings: [189 {190 localId: 'GF-1',191 severity: 'P2',192 issue: 'Missing info',193 rationale: 'no question provided',194 },195 ],196 }),197 );198 199 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);200 expect(decision.kind).toBe('blocked');201 });202 203 it('should return cap_escalation when at cap with blocking findings', async () => {204 gateState.reviewCount = 4; // next will be 5 (= CAPPED_REVIEW_LIMIT)205 mockRunGateAgent.mockResolvedValue(206 makeResult({207 decision: 'blocked',208 findings: [209 {210 localId: 'GF-1',211 severity: 'P1',212 issue: 'Still broken',213 rationale: 'unresolved',214 },215 ],216 }),217 );218 219 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);220 expect(decision.kind).toBe('cap_escalation');221 });222 223 it('should approve with non-blocking notes when at cap with only P3 findings', async () => {224 gateState.reviewCount = 4;225 mockRunGateAgent.mockResolvedValue(226 makeResult({227 decision: 'blocked',228 findings: [229 {230 localId: 'GF-1',231 severity: 'P3',232 issue: 'Minor style',233 rationale: 'nit',234 },235 ],236 }),237 );238 239 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);240 expect(decision.kind).toBe('approved');241 expect(242 (decision as { nonBlockingFindings?: unknown[] }).nonBlockingFindings,243 ).toHaveLength(1);244 });245 246 it('should return unavailable when agent exhausts retries', async () => {247 mockRunGateAgent.mockRejectedValue(new Error('network error'));248 249 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);250 expect(decision.kind).toBe('unavailable');251 expect((decision as { reason: string }).reason).toContain('retries');252 });253});254 255// ── Cap logic tests ───────────────────────────────────────────────────256 257describe('runPlanApprovalGate decision edge cases', () => {258 let gateState: PlanGateState;259 let mockConfig: Config;260 const signal = new AbortController().signal;261 262 const bundle: EvidenceBundle = {263 originalRequest: 'Add a button',264 plan: 'Step 1: add button component',265 };266 267 beforeEach(() => {268 gateState = createPlanGateState(1);269 mockConfig = {270 getPlanGateState: vi.fn(() => gateState),271 getSubagentManager: vi.fn(),272 } as unknown as Config;273 mockRunGateAgent.mockReset();274 });275 276 it('should return unavailable when needs_user has empty findings', async () => {277 mockRunGateAgent.mockResolvedValue(278 makeResult({ decision: 'needs_user', findings: [] }),279 );280 281 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);282 expect(decision.kind).toBe('unavailable');283 expect((decision as { reason: string }).reason).toContain('needs_user');284 });285 286 it('should return unavailable when blocked has empty findings', async () => {287 mockRunGateAgent.mockResolvedValue(288 makeResult({ decision: 'blocked', findings: [] }),289 );290 291 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);292 expect(decision.kind).toBe('unavailable');293 expect((decision as { reason: string }).reason).toContain('blocked');294 });295 296 it('should treat pass-with-findings as blocked', async () => {297 mockRunGateAgent.mockResolvedValue(298 makeResult({299 decision: 'pass',300 findings: [301 {302 localId: 'GF-1',303 severity: 'P2',304 issue: 'Anomalous finding',305 rationale: 'should not pass',306 },307 ],308 }),309 );310 311 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);312 expect(decision.kind).toBe('blocked');313 });314 315 it('should return unavailable when pre-aborted signal', async () => {316 const abortController = new AbortController();317 abortController.abort();318 mockRunGateAgent.mockRejectedValue(new Error('aborted'));319 320 const decision = await runPlanApprovalGate(321 mockConfig,322 bundle,323 abortController.signal,324 );325 expect(decision.kind).toBe('unavailable');326 });327 328 it('should succeed after partial retries (fail 2, then succeed)', async () => {329 mockRunGateAgent330 .mockRejectedValueOnce(new Error('transient 1'))331 .mockRejectedValueOnce(new Error('transient 2'))332 .mockResolvedValueOnce(makeResult({ decision: 'pass' }));333 334 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);335 expect(decision.kind).toBe('approved');336 expect(mockRunGateAgent).toHaveBeenCalledTimes(3);337 });338 339 it('should handle uncapped mode (findings still block without cap escalation)', async () => {340 gateState.gateMode = 'uncapped';341 gateState.reviewCount = 10;342 mockRunGateAgent.mockResolvedValue(343 makeResult({344 decision: 'blocked',345 findings: [346 {347 localId: 'GF-1',348 severity: 'P1',349 issue: 'Critical',350 rationale: 'bad',351 },352 ],353 }),354 );355 356 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);357 expect(decision.kind).toBe('blocked');358 });359 360 it('should increment reviewCount on each gate run', async () => {361 expect(gateState.reviewCount).toBe(0);362 mockRunGateAgent.mockResolvedValue(makeResult({ decision: 'pass' }));363 364 await runPlanApprovalGate(mockConfig, bundle, signal);365 expect(gateState.reviewCount).toBe(1);366 });367 368 it('should store findings in gateState.lastFindings', async () => {369 mockRunGateAgent.mockResolvedValue(370 makeResult({371 decision: 'blocked',372 findings: [373 {374 localId: 'GF-1',375 severity: 'P2',376 issue: 'Test issue',377 rationale: 'test',378 },379 ],380 }),381 );382 383 await runPlanApprovalGate(mockConfig, bundle, signal);384 expect(gateState.lastFindings).toHaveLength(1);385 expect(gateState.lastFindings[0]!.id).toBe('GF-1');386 });387 388 it('P3-only at cap approves with nonBlockingFindings', async () => {389 gateState.reviewCount = 4;390 mockRunGateAgent.mockResolvedValue(391 makeResult({392 decision: 'blocked',393 findings: [394 {395 localId: 'GF-1',396 severity: 'P3',397 issue: 'Minor',398 rationale: 'nit',399 },400 {401 localId: 'GF-2',402 severity: 'P3',403 issue: 'Also minor',404 rationale: 'style',405 },406 ],407 }),408 );409 410 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);411 expect(decision.kind).toBe('approved');412 expect(413 (decision as { nonBlockingFindings?: unknown[] }).nonBlockingFindings,414 ).toHaveLength(2);415 });416 417 it('P1 at cap triggers cap_escalation with only blocking findings', async () => {418 gateState.reviewCount = 4;419 mockRunGateAgent.mockResolvedValue(420 makeResult({421 decision: 'blocked',422 findings: [423 {424 localId: 'GF-1',425 severity: 'P1',426 issue: 'Critical',427 rationale: 'bad',428 },429 {430 localId: 'GF-2',431 severity: 'P3',432 issue: 'Minor',433 rationale: 'nit',434 },435 ],436 }),437 );438 439 const decision = await runPlanApprovalGate(mockConfig, bundle, signal);440 expect(decision.kind).toBe('cap_escalation');441 const escalation = decision as { blockingFindings: Array<{ id: string }> };442 expect(escalation.blockingFindings).toHaveLength(1);443 expect(escalation.blockingFindings[0]!.id).toBe('GF-1');444 });445});446 