mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-04 07:27:20 +00:00
* feat(api_impact): list the callers of the requesting function as indirect consumers The FETCHES edge is file-level, so api_impact saw only the file that sends the request. The files that call its request function never mention the URL and were missing from the report (#3644). The edge's reason now names the callables whose spans contain the request sites, one `|via:<id>` field each, under the containment rule the R3-6 sink set already uses; that rule moves into EnclosingCallableIndex so both stay one. Several sites of one file for one route now share the edge instead of colliding on its id. api_impact follows the CALLS edges into those callables one level up and reports the callers as `indirectConsumers`, apart from the direct consumers and outside the risk level. Direct consumer files and the handler file are left out. An index built before the attribution carries no field. Expo navigation sites carry their real line so they attribute to the component that contains them. Closes #3644 * fix(api_impact): attribute a request site by its AST enclosing callable A helper defined on the line of the request has the smaller span, so the line-only lookup named it and the callers of the real requesting function vanished from indirectConsumers. The parser now records the enclosing callable of each fetch site and of each wrapper call (through the call's position), and attribution prefers that identity; the span lookup remains for sites found by text scan. The same preference applies to the R3-6 sink set. A real analyze of the fixture followed by api_impact pins the helper case and the wrapper case. * test(bench): rebaseline receiver resolution for the indirect-consumers fixture The api-impact-indirect-consumers fixture adds three receiver-unresolved sites to the corpus: two `.then(...)` calls on the untyped return of `fetch(...)` and of the local wrapper `apiFetch(...)`, and one `req.query.path` read inside an inline Express handler with an untyped `req`. Call drops 138 -> 140, reads 30 -> 31, all kinds 168 -> 171. No shape-arm state and no existing site changed. * fix(api_impact): preserve module-scope request attribution Keep AST-confirmed module scope distinct from missing attribution for direct fetches, HTTP clients, and wrapper calls. Prevent overlapping callable spans from inventing indirect consumers or process sinks. Preserve span fallback for callable forms the generic identity walk cannot name. Add eight regression cases, including real analyze/api_impact coverage. Rebaseline receiver-resolution counts for the two then calls and one property read added by this PR's existing fixtures; shape expectations are unchanged. --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
375 lines
16 KiB
TypeScript
375 lines
16 KiB
TypeScript
/**
|
|
* E2E Integration Tests: Deep Flow Detection (api_impact, route_map, shape_check)
|
|
*
|
|
* Tests the full stack: LadybugDB seed → MCP tool call → result verification.
|
|
* Covers response shape extraction, mismatch detection, middleware chains,
|
|
* multi-fetch attribution, and error shape separation.
|
|
*
|
|
* Uses hand-crafted Cypher seed data that represents what the pipeline
|
|
* would produce for a Next.js project with API routes and consumers.
|
|
*/
|
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
|
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
|
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
|
import { API_IMPACT_SEED_DATA, API_IMPACT_FTS_INDEXES } from '../fixtures/api-impact-seed.js';
|
|
|
|
vi.mock('../../src/storage/repo-manager.js', () => ({
|
|
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
|
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
|
findSiblingClones: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
withTestLbugDB(
|
|
'api-impact-e2e',
|
|
(handle) => {
|
|
let backend: LocalBackend;
|
|
|
|
beforeAll(async () => {
|
|
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
|
if (!ext._backend) {
|
|
throw new Error(
|
|
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
|
);
|
|
}
|
|
backend = ext._backend;
|
|
});
|
|
|
|
// ─── Test 1: api_impact round-trip ─────────────────────────────────
|
|
|
|
describe('api_impact round-trip', () => {
|
|
it('returns response shape with success and error keys for /api/grants', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.route).toBe('/api/grants');
|
|
expect(result.handler).toBe('app/api/grants/route.ts');
|
|
|
|
// Verify success keys
|
|
expect(result.responseShape).toBeDefined();
|
|
expect(result.responseShape.success).toContain('data');
|
|
expect(result.responseShape.success).toContain('pagination');
|
|
|
|
// Verify error keys
|
|
expect(result.responseShape.error).toContain('error');
|
|
expect(result.responseShape.error).toContain('message');
|
|
});
|
|
|
|
it('includes GrantsList and useGrants as consumers', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
expect(result.consumers).toBeDefined();
|
|
expect(result.consumers.length).toBeGreaterThanOrEqual(2);
|
|
|
|
const consumerFiles = result.consumers.map((c: any) => c.file);
|
|
expect(consumerFiles).toContain('components/GrantsList.tsx');
|
|
expect(consumerFiles).toContain('hooks/useGrants.ts');
|
|
});
|
|
|
|
it('returns impact summary with risk level', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
expect(result.impactSummary).toBeDefined();
|
|
expect(result.impactSummary.directConsumers).toBeGreaterThanOrEqual(2);
|
|
expect(['LOW', 'MEDIUM', 'HIGH']).toContain(result.impactSummary.riskLevel);
|
|
});
|
|
});
|
|
|
|
// #3488: files passing the URL to an unproven function make the count a floor.
|
|
describe('api_impact lower bound', () => {
|
|
it.each([
|
|
{ route: '/api/grants', lowerBound: {} },
|
|
{
|
|
route: '/api/secure',
|
|
lowerBound: {
|
|
epistemic: 'lower-bound',
|
|
unverifiedConsumers: 2,
|
|
boundaries: [
|
|
"2 more files pass this route's URL to a function not proven to send HTTP requests; directConsumers is a lower bound.",
|
|
],
|
|
},
|
|
},
|
|
])('$route reports unverified consumers', async ({ route, lowerBound }) => {
|
|
const result = await backend.callTool('api_impact', { route });
|
|
const { epistemic, unverifiedConsumers, boundaries } = result.impactSummary;
|
|
expect(JSON.parse(JSON.stringify({ epistemic, unverifiedConsumers, boundaries }))).toEqual(
|
|
lowerBound,
|
|
);
|
|
});
|
|
});
|
|
|
|
// #3644: the callers of the function that sends the request, one level up.
|
|
describe('api_impact indirect consumers', () => {
|
|
it('lists the callers of the requesting function apart from the direct consumers', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/secure' });
|
|
|
|
expect(result.indirectConsumers).toEqual([
|
|
{ name: 'App', file: 'App.tsx', via: 'fetchSecure' },
|
|
{ name: 'Header', file: 'components/Header.tsx', via: 'fetchSecure' },
|
|
]);
|
|
expect(result.impactSummary.indirectConsumers).toBe(2);
|
|
// useMulti calls fetchSecure too, but it fetches the route itself.
|
|
expect(result.consumers.map((c: any) => c.file)).toEqual(
|
|
expect.arrayContaining(['hooks/useMulti.ts', 'services/backend-client.ts']),
|
|
);
|
|
});
|
|
|
|
it('does not let indirect consumers change the risk level', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/secure' });
|
|
|
|
expect(result.impactSummary.directConsumers).toBe(2);
|
|
expect(result.impactSummary.riskLevel).toBe('LOW');
|
|
});
|
|
|
|
it('leaves the field out when no consumer edge names a requesting function', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
expect(result).not.toHaveProperty('indirectConsumers');
|
|
expect(result.impactSummary).not.toHaveProperty('indirectConsumers');
|
|
});
|
|
});
|
|
|
|
// ─── Test 2: Mismatch detection ────────────────────────────────────
|
|
|
|
describe('mismatch detection e2e', () => {
|
|
it('detects mismatches when consumer accesses field not in handler response', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
// useGrants accesses 'items' which is not in grants route responseKeys [data, pagination]
|
|
// useMulti accesses 'meta' which is not in grants route responseKeys
|
|
expect(result.mismatches).toBeDefined();
|
|
expect(result.mismatches.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// Check that at least one mismatch has a field and consumer
|
|
const mismatch = result.mismatches[0];
|
|
expect(mismatch).toHaveProperty('consumer');
|
|
expect(mismatch).toHaveProperty('field');
|
|
expect(mismatch).toHaveProperty('confidence');
|
|
expect(['high', 'low']).toContain(mismatch.confidence);
|
|
});
|
|
|
|
it('flags items access from useGrants as a mismatch with high confidence', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
const itemsMismatch = result.mismatches.find(
|
|
(m: any) => m.field === 'items' && m.consumer === 'hooks/useGrants.ts',
|
|
);
|
|
expect(itemsMismatch).toBeDefined();
|
|
// useGrants only fetches one route, so confidence should be high
|
|
expect(itemsMismatch!.confidence).toBe('high');
|
|
});
|
|
|
|
it('bumps risk level when mismatches exist', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
// With 3 consumers (LOW base) + mismatches → should be at least MEDIUM
|
|
expect(['MEDIUM', 'HIGH']).toContain(result.impactSummary.riskLevel);
|
|
});
|
|
});
|
|
|
|
// ─── Test 3: Multi-fetch attribution ───────────────────────────────
|
|
|
|
describe('multi-fetch attribution', () => {
|
|
it('adds attributionNote for multi-fetch consumers', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
const multiConsumer = result.consumers.find((c: any) => c.file === 'hooks/useMulti.ts');
|
|
expect(multiConsumer).toBeDefined();
|
|
expect(multiConsumer!.attributionNote).toBeDefined();
|
|
expect(multiConsumer!.attributionNote).toContain('fetches 2 routes');
|
|
});
|
|
|
|
it('marks multi-fetch mismatches with low confidence', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
// useMulti accesses 'meta' on /api/grants — but since it fetches 2 routes,
|
|
// the mismatch confidence should be 'low'
|
|
const metaMismatch = result.mismatches.find(
|
|
(m: any) => m.field === 'meta' && m.consumer === 'hooks/useMulti.ts',
|
|
);
|
|
expect(metaMismatch).toBeDefined();
|
|
expect(metaMismatch!.confidence).toBe('low');
|
|
});
|
|
});
|
|
|
|
// ─── Test 4: Middleware chain round-trip ────────────────────────────
|
|
|
|
describe('middleware chain round-trip', () => {
|
|
it('returns middleware array for wrapped route', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/secure' });
|
|
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.middleware).toBeDefined();
|
|
expect(result.middleware).toContain('withAuth');
|
|
expect(result.middleware).toContain('withRateLimit');
|
|
});
|
|
|
|
it('flags middlewareDetection as partial when handler has multiple method exports', async () => {
|
|
// /api/secure has both GET (wrapped) and POST (unwrapped) in the same file
|
|
// The route appears twice in results (one per method export via HANDLES_ROUTE)
|
|
// but fetchRoutesWithConsumers deduplicates by Route node ID, so we get one
|
|
// Route node with 2 HANDLES_ROUTE edges → routeCountByHandler > 1
|
|
const result = await backend.callTool('api_impact', { route: '/api/secure' });
|
|
|
|
// Since two route handler functions point to the same file,
|
|
// and there's only one Route node, middlewareDetection should be partial
|
|
// if the route count per handler > 1
|
|
// Note: The actual behavior depends on whether multiple HANDLES_ROUTE edges
|
|
// cause multiple route entries. Let's verify middleware is present at minimum.
|
|
expect(result.middleware.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('route_map also shows middleware', async () => {
|
|
const result = await backend.callTool('route_map', { route: '/api/secure' });
|
|
|
|
expect(result.routes).toBeDefined();
|
|
const secureRoute = result.routes.find((r: any) => r.route === '/api/secure');
|
|
expect(secureRoute).toBeDefined();
|
|
expect(secureRoute!.middleware).toContain('withAuth');
|
|
expect(secureRoute!.middleware).toContain('withRateLimit');
|
|
});
|
|
});
|
|
|
|
// ─── Test 5: Error shape separation ────────────────────────────────
|
|
|
|
describe('error shape separation', () => {
|
|
it('stores responseKeys and errorKeys separately', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
// Success keys
|
|
expect(result.responseShape.success).toEqual(
|
|
expect.arrayContaining(['data', 'pagination']),
|
|
);
|
|
// Error keys
|
|
expect(result.responseShape.error).toEqual(expect.arrayContaining(['error', 'message']));
|
|
// They should be distinct sets
|
|
const successSet = new Set(result.responseShape.success);
|
|
const errorSet = new Set(result.responseShape.error);
|
|
// No overlap between success and error keys in this fixture
|
|
for (const key of errorSet) {
|
|
expect(successSet.has(key)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('consumer accessing error key does not trigger mismatch', async () => {
|
|
// GrantsList accesses 'data' and 'pagination' — both in success keys
|
|
// Neither triggers a mismatch
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
const grantsListMismatches =
|
|
result.mismatches?.filter((m: any) => m.consumer === 'components/GrantsList.tsx') ?? [];
|
|
expect(grantsListMismatches.length).toBe(0);
|
|
});
|
|
|
|
it('shape_check shows both responseKeys and errorKeys', async () => {
|
|
const result = await backend.callTool('shape_check', { route: '/api/grants' });
|
|
|
|
expect(result.routes).toBeDefined();
|
|
const grantsRoute = result.routes.find((r: any) => r.route === '/api/grants');
|
|
expect(grantsRoute).toBeDefined();
|
|
|
|
// shapeCheck returns responseKeys and errorKeys separately
|
|
if (grantsRoute!.responseKeys) {
|
|
expect(grantsRoute!.responseKeys).toContain('data');
|
|
expect(grantsRoute!.responseKeys).toContain('pagination');
|
|
}
|
|
if (grantsRoute!.errorKeys) {
|
|
expect(grantsRoute!.errorKeys).toContain('error');
|
|
expect(grantsRoute!.errorKeys).toContain('message');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Test 6: Tool consistency ──────────────────────────────────────
|
|
|
|
describe('tool consistency across route_map, shape_check, api_impact', () => {
|
|
it('consumer counts match across all 3 tools for /api/grants', async () => {
|
|
const [routeMapResult, shapeCheckResult, apiImpactResult] = await Promise.all([
|
|
backend.callTool('route_map', { route: '/api/grants' }),
|
|
backend.callTool('shape_check', { route: '/api/grants' }),
|
|
backend.callTool('api_impact', { route: '/api/grants' }),
|
|
]);
|
|
|
|
// route_map consumers
|
|
const rmRoute = routeMapResult.routes.find((r: any) => r.route === '/api/grants');
|
|
expect(rmRoute).toBeDefined();
|
|
const rmConsumerCount = rmRoute!.consumers.length;
|
|
|
|
// shape_check consumers
|
|
const scRoute = shapeCheckResult.routes.find((r: any) => r.route === '/api/grants');
|
|
expect(scRoute).toBeDefined();
|
|
const scConsumerCount = scRoute!.consumers.length;
|
|
|
|
// api_impact consumers
|
|
const aiConsumerCount = apiImpactResult.consumers.length;
|
|
|
|
// All three should report the same number of consumers
|
|
expect(rmConsumerCount).toBe(aiConsumerCount);
|
|
expect(scConsumerCount).toBe(aiConsumerCount);
|
|
});
|
|
|
|
it('all tools return the same handler file', async () => {
|
|
const [routeMapResult, shapeCheckResult, apiImpactResult] = await Promise.all([
|
|
backend.callTool('route_map', { route: '/api/grants' }),
|
|
backend.callTool('shape_check', { route: '/api/grants' }),
|
|
backend.callTool('api_impact', { route: '/api/grants' }),
|
|
]);
|
|
|
|
const rmHandler = routeMapResult.routes.find(
|
|
(r: any) => r.route === '/api/grants',
|
|
)?.handler;
|
|
const scHandler = shapeCheckResult.routes.find(
|
|
(r: any) => r.route === '/api/grants',
|
|
)?.handler;
|
|
const aiHandler = apiImpactResult.handler;
|
|
|
|
expect(rmHandler).toBe('app/api/grants/route.ts');
|
|
expect(scHandler).toBe('app/api/grants/route.ts');
|
|
expect(aiHandler).toBe('app/api/grants/route.ts');
|
|
});
|
|
});
|
|
|
|
// ─── Edge cases ────────────────────────────────────────────────────
|
|
|
|
describe('api_impact edge cases', () => {
|
|
it('returns error when neither route nor file is provided', async () => {
|
|
const result = await backend.callTool('api_impact', {});
|
|
expect(result).toHaveProperty('error');
|
|
expect(result.error).toMatch(/required/i);
|
|
});
|
|
|
|
it('returns error for nonexistent route', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/nonexistent' });
|
|
expect(result).toHaveProperty('error');
|
|
expect(result.error).toMatch(/no routes found/i);
|
|
});
|
|
|
|
it('can look up route by file path', async () => {
|
|
const result = await backend.callTool('api_impact', { file: 'app/api/grants/route.ts' });
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.route).toBe('/api/grants');
|
|
});
|
|
});
|
|
},
|
|
{
|
|
seed: API_IMPACT_SEED_DATA,
|
|
ftsIndexes: API_IMPACT_FTS_INDEXES,
|
|
poolAdapter: true,
|
|
afterSetup: async (handle) => {
|
|
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
|
{
|
|
name: 'test-api-repo',
|
|
path: '/test/api-repo',
|
|
storagePath: handle.tmpHandle.dbPath,
|
|
indexedAt: new Date().toISOString(),
|
|
lastCommit: 'def456',
|
|
stats: { files: 5, nodes: 10, communities: 1, processes: 0 },
|
|
},
|
|
]);
|
|
|
|
const backend = new LocalBackend();
|
|
await backend.init();
|
|
(handle as any)._backend = backend;
|
|
},
|
|
},
|
|
);
|