From 149e8ee7358ca457df61d3c8c4ec5f86f7f0cce6 Mon Sep 17 00:00:00 2001 From: ivkond Date: Thu, 9 Apr 2026 02:36:38 +0300 Subject: [PATCH] fix(group): restore out-of-scope reporting and phase1 timeout semantics --- gitnexus/src/core/group/cross-impact.ts | 23 +++- gitnexus/test/unit/group/cross-impact.test.ts | 111 ++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index b06d3b178..4085b2fba 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -117,6 +117,11 @@ function mergeRisk( return order[idx] ?? base; } +function computePhase1Timeout(timeout: number): number { + if (timeout <= 1000) return timeout; + return Math.min(Math.ceil(timeout * 0.3), 10000); +} + export async function runGroupImpactLegacy( opts: LegacyGroupImpactOptions, ): Promise { @@ -126,7 +131,7 @@ export async function runGroupImpactLegacy( const tStart = Date.now(); const wallDeadline = tStart + timeout; - const phase1Timeout = Math.min(5000, timeout); + const phase1Timeout = computePhase1Timeout(timeout); const localResult = await Promise.race([ opts.localImpactFn(opts.target, opts.direction).then((v) => ({ ok: true as const, v })), @@ -309,7 +314,7 @@ export async function runGroupImpact(opts: GroupImpactOptions): Promise ({ ok: true as const, v })), @@ -335,6 +340,7 @@ export async function runGroupImpact(opts: GroupImpactOptions): Promise(); for (const row of rows) { + if (!inSubgroup(row.fanOutRepo, opts.subgroup)) { + outOfScope.push({ + from: row.fanOutRepo, + to: opts.repoPath, + contractId: row.contractId, + matchType: row.matchType as OutOfScopeLink['matchType'], + confidence: row.confidence, + }); + continue; + } + if (Date.now() > wallDeadline) { truncated = true; break; @@ -414,7 +431,7 @@ export async function runGroupImpact(opts: GroupImpactOptions): Promise { expect(result.truncated).toBe(true); }); + + it.each([ + [500, 500], + [5000, 1500], + [30000, 9000], + [60000, 10000], + ])( + 'test_runGroupImpactLegacy_phase1_timeout_contract_%i_to_%i', + async (timeout, expectedPhase1Timeout) => { + const t0 = Date.now(); + const result = await runGroupImpactLegacy({ + groupName: 'test', + target: 'slowTarget', + repoPath: 'app/backend', + direction: 'upstream', + timeout, + registry: mockRegistry, + localImpactFn: async () => { + await new Promise((resolve) => setTimeout(resolve, expectedPhase1Timeout + 50)); + return { + target: { id: '', name: 'slowTarget', filePath: '' }, + direction: 'upstream', + impactedCount: 0, + risk: 'LOW', + summary: {}, + affected_processes: [], + affected_modules: [], + byDepth: {}, + }; + }, + crossImpactFn: async () => null, + }); + expect(result.truncated).toBe(true); + expect(Date.now() - t0).toBeLessThan(expectedPhase1Timeout + 250); + }, + ); }); describe('runGroupImpact (Cypher-based)', () => { @@ -368,6 +404,45 @@ describe('runGroupImpact (Cypher-based)', () => { expect(cypher).toContain('$subgroup'); }); + it('test_runGroupImpact_subgroup_filtered_rows_are_reported_out_of_scope', async () => { + const bridgeQuery = makeBridgeQuery([ + { + fanOutRepo: 'other/team/frontend', + fanOutUid: 'uid-fetch', + fanOutFilePath: 'src/api.ts', + fanOutSymbolName: 'fetchUsers', + matchedLocalUid: 'uid-ctrl', + matchedLocalFilePath: 'src/ctrl.ts', + matchedLocalSymbolName: 'UserController.list', + matchType: 'exact', + confidence: 1.0, + contractId: 'http::GET::/api/users', + contractType: 'http', + }, + ]); + + const result = await runGroupImpact({ + groupName: 'test', + target: 'UserController.list', + repoPath: 'app/backend', + direction: 'upstream', + subgroup: 'team/backend', + bridgeQuery, + localImpactFn, + crossImpactFn: vi.fn().mockResolvedValue(null), + }); + + expect(result.cross).toHaveLength(0); + expect(result.outOfScope).toEqual([ + expect.objectContaining({ + from: 'other/team/frontend', + to: 'app/backend', + contractId: 'http::GET::/api/users', + matchType: 'exact', + }), + ]); + }); + it('test_runGroupImpact_error_object_not_counted_as_hit', async () => { const bridgeQuery = makeBridgeQuery([ { @@ -399,4 +474,40 @@ describe('runGroupImpact (Cypher-based)', () => { expect(result.cross).toHaveLength(0); expect(result.summary.cross_repo_hits).toBe(0); }); + + it.each([ + [500, 500], + [5000, 1500], + [30000, 9000], + [60000, 10000], + ])( + 'test_runGroupImpact_phase1_timeout_contract_%i_to_%i', + async (timeout, expectedPhase1Timeout) => { + const t0 = Date.now(); + const result = await runGroupImpact({ + groupName: 'test', + target: 'slowTarget', + repoPath: 'app/backend', + direction: 'upstream', + timeout, + bridgeQuery: vi.fn().mockResolvedValue([]), + localImpactFn: async () => { + await new Promise((resolve) => setTimeout(resolve, expectedPhase1Timeout + 50)); + return { + target: { id: '', name: 'slowTarget', filePath: '' }, + direction: 'upstream', + impactedCount: 0, + risk: 'LOW', + summary: {}, + affected_processes: [], + affected_modules: [], + byDepth: {}, + }; + }, + crossImpactFn: vi.fn().mockResolvedValue(null), + }); + expect(result.truncated).toBe(true); + expect(Date.now() - t0).toBeLessThan(expectedPhase1Timeout + 250); + }, + ); });