fix(group): restore out-of-scope reporting and phase1 timeout semantics

This commit is contained in:
ivkond 2026-04-09 02:36:38 +03:00
parent ae68bfbfb2
commit 149e8ee735
2 changed files with 131 additions and 3 deletions

View file

@ -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<GroupImpactResult> {
@ -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<GroupImp
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 })),
@ -335,6 +340,7 @@ export async function runGroupImpact(opts: GroupImpactOptions): Promise<GroupImp
const uids = collectPhase1Uids(local);
const phase1Refs = collectPhase1Refs(local);
const cross: CrossRepoImpact[] = [];
const outOfScope: OutOfScopeLink[] = [];
const truncatedRepos: string[] = [];
/* Phase 2 — Cypher bridge query */
@ -365,6 +371,17 @@ export async function runGroupImpact(opts: GroupImpactOptions): Promise<GroupImp
const distinctRepos = new Set<string>();
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<GroupImp
local,
group: opts.groupName,
cross,
outOfScope: [],
outOfScope,
truncated,
truncatedRepos,
summary: {

View file

@ -188,6 +188,42 @@ describe('runGroupImpactLegacy', () => {
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);
},
);
});