diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 956161c618..f41dfe713b 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1403,13 +1403,30 @@ export const processRoutesFromExtracted = async ( */ /** Common method names on response/data objects that are NOT property accesses */ -const RESPONSE_METHOD_BLOCKLIST = new Set([ - 'json', 'text', 'blob', 'arrayBuffer', 'formData', 'ok', 'status', 'headers', - 'then', 'catch', 'finally', 'clone', +// Properties/methods to ignore when extracting consumer accessed keys from `data.X` patterns. +// Avoids false positives from Fetch API, Array, Object, Promise, and DOM access on variables +// that happen to share names with response variables (data, result, response, etc.). +const RESPONSE_ACCESS_BLOCKLIST = new Set([ + // Fetch/Response API + 'json', 'text', 'blob', 'arrayBuffer', 'formData', 'ok', 'status', 'headers', 'clone', + // Promise + 'then', 'catch', 'finally', + // Array 'map', 'filter', 'forEach', 'reduce', 'find', 'some', 'every', - 'length', 'toString', 'valueOf', 'push', 'pop', 'shift', 'unshift', 'splice', 'slice', 'concat', 'join', - 'sort', 'reverse', 'includes', 'indexOf', 'keys', 'values', 'entries', + 'sort', 'reverse', 'includes', 'indexOf', + // Object + 'length', 'toString', 'valueOf', 'keys', 'values', 'entries', + // DOM methods — file-download patterns often reuse `data`/`response` variable names + 'appendChild', 'removeChild', 'insertBefore', 'replaceChild', 'replaceChildren', + 'createElement', 'getElementById', 'querySelector', 'querySelectorAll', + 'setAttribute', 'getAttribute', 'removeAttribute', 'hasAttribute', + 'addEventListener', 'removeEventListener', 'dispatchEvent', + 'classList', 'className', + 'parentNode', 'parentElement', 'childNodes', 'children', + 'nextSibling', 'previousSibling', 'firstChild', 'lastChild', + 'click', 'focus', 'blur', 'submit', 'reset', + 'innerHTML', 'outerHTML', 'textContent', 'innerText', ]); export const extractConsumerAccessedKeys = (content: string): string[] => { @@ -1448,7 +1465,7 @@ export const extractConsumerAccessedKeys = (content: string): string[] => { while ((match = propAccessPattern.exec(content)) !== null) { const key = match[1]; // Skip common method calls that aren't property accesses - if (!RESPONSE_METHOD_BLOCKLIST.has(key)) { + if (!RESPONSE_ACCESS_BLOCKLIST.has(key)) { keys.add(key); } } diff --git a/gitnexus/src/core/ingestion/route-extractors/response-shapes.ts b/gitnexus/src/core/ingestion/route-extractors/response-shapes.ts index 2de2b9d9dd..2ff18fac99 100644 --- a/gitnexus/src/core/ingestion/route-extractors/response-shapes.ts +++ b/gitnexus/src/core/ingestion/route-extractors/response-shapes.ts @@ -76,7 +76,31 @@ export function extractResponseShapes(content: string): { responseKeys?: string[ if (ch === inString) inString = null; continue; } - if (ch === '"' || ch === "'" || ch === '`') { inString = ch; continue; } + if (ch === '"' || ch === "'" || ch === '`') { + // Quoted string at depth 1 before ':' is a property key (e.g., { 'courses': data }) + // The original parser only handled unquoted identifiers. + if (depth === 1 && keyStart === -1) { + const quote = ch; + const strStart = j + 1; + let strEnd = -1; + for (let s = strStart; s < content.length; s++) { + if (content[s] === '\\') { s++; continue; } + if (content[s] === quote) { strEnd = s; break; } + } + if (strEnd !== -1) { + // Scan forward for ':' without allocating a substring + let p = strEnd + 1; + while (p < content.length && (content[p] === ' ' || content[p] === '\t' || content[p] === '\n' || content[p] === '\r')) p++; + if (content[p] === ':') { + callKeys.push(content.slice(strStart, strEnd)); + } + j = strEnd; + continue; + } + } + inString = ch; + continue; + } if (ch === '{') { depth++; continue; } if (ch === '}') { depth--; if (depth === 0) { closingBracePos = j; break; } continue; } if (depth !== 1) continue; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 9d30154546..161906b1a1 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -1653,14 +1653,19 @@ export class LocalBackend { r.reason AS fetchReason `, params); + // Strip wrapping quotes from DB array elements — CSV COPY stores ['key'] which + // LadybugDB may return as "'key'" rather than "key" + const stripQuotes = (keys: string[] | null): string[] | null => + keys ? keys.map(k => k.replace(/^['"]|['"]$/g, '')) : null; + const routeMap = new Map }>(); for (const row of rows) { const id = row.routeId ?? row[0]; const name = row.routeName ?? row[1]; const filePath = row.handlerFile ?? row[2]; - const responseKeys: string[] | null = row.responseKeys ?? row[3] ?? null; - const errorKeys: string[] | null = row.errorKeys ?? row[4] ?? null; - const middleware: string[] | null = row.middleware ?? row[5] ?? null; + const responseKeys = stripQuotes(row.responseKeys ?? row[3] ?? null); + const errorKeys = stripQuotes(row.errorKeys ?? row[4] ?? null); + const middleware = stripQuotes(row.middleware ?? row[5] ?? null); const consumerName = row.consumerName ?? row[6]; const consumerFile = row.consumerFile ?? row[7]; const fetchReason: string | null = row.fetchReason ?? row[8] ?? null; @@ -1755,23 +1760,28 @@ export class LocalBackend { const results = allRoutes .filter(r => ((r.responseKeys && r.responseKeys.length > 0) || (r.errorKeys && r.errorKeys.length > 0)) && r.consumers.length > 0) .map(r => { + // Keys already normalized by fetchRoutesWithConsumers (quotes stripped) const responseKeys = r.responseKeys ?? []; const errorKeys = r.errorKeys ?? []; // Combined set: consumer accessing either success or error keys is valid const allKnownKeys = new Set([...responseKeys, ...errorKeys]); // Check each consumer's accessed keys against the route's response shape + const responseKeySet = new Set(responseKeys); const consumers = r.consumers.map(c => { if (!c.accessedKeys || c.accessedKeys.length === 0) { return { name: c.name, filePath: c.filePath }; } const mismatched = c.accessedKeys.filter(k => !allKnownKeys.has(k)); + // Keys in allKnownKeys but not in responseKeys — error-path access (e.g., .error from errorKeys) + const errorPathKeys = c.accessedKeys.filter(k => allKnownKeys.has(k) && !responseKeySet.has(k)); const isMultiFetch = (c.fetchCount ?? 1) > 1; return { name: c.name, filePath: c.filePath, accessedKeys: c.accessedKeys, ...(mismatched.length > 0 ? { mismatched, mismatchConfidence: isMultiFetch ? 'low' as const : 'high' as const } : {}), + ...(errorPathKeys.length > 0 ? { errorPathKeys } : {}), ...(isMultiFetch ? { attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.` } : {}), }; }); @@ -1873,6 +1883,7 @@ export class LocalBackend { } const results = routes.map(r => { + // Keys already normalized by fetchRoutesWithConsumers (quotes stripped) const responseKeys = r.responseKeys ?? []; const errorKeys = r.errorKeys ?? []; const allKnownKeys = new Set([...responseKeys, ...errorKeys]); diff --git a/gitnexus/test/fixtures/lang-resolution/shape-check-integration/app/api/gdpr/export/route.ts b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/app/api/gdpr/export/route.ts new file mode 100644 index 0000000000..e5391adbb3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/app/api/gdpr/export/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from 'next/server'; + +export async function POST() { + const archive = await buildExport(); + return NextResponse.json({ url: archive.url }); +} diff --git a/gitnexus/test/fixtures/lang-resolution/shape-check-integration/app/api/search/route.ts b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/app/api/search/route.ts new file mode 100644 index 0000000000..e5ae8f098a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/app/api/search/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + const courses = await getCourses(); + const articles = await getArticles(); + return NextResponse.json({ 'courses': courses, 'articles': articles }); +} diff --git a/gitnexus/test/fixtures/lang-resolution/shape-check-integration/app/api/users/route.ts b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/app/api/users/route.ts new file mode 100644 index 0000000000..5d22f32440 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/app/api/users/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + const users = await getUsers(); + return NextResponse.json({ data: users, total: users.length }); +} + +export async function POST(req: Request) { + const body = await req.json(); + if (!body.name) { + return NextResponse.json({ error: 'Name required', details: 'missing field' }, { status: 400 }); + } + return NextResponse.json({ data: body, success: true }); +} diff --git a/gitnexus/test/fixtures/lang-resolution/shape-check-integration/components/GdprExport.tsx b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/components/GdprExport.tsx new file mode 100644 index 0000000000..97d6aec62e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/components/GdprExport.tsx @@ -0,0 +1,9 @@ +export function GdprExport() { + const data = fetch('/api/gdpr/export', { method: 'POST' }).then(r => r.json()); + const link = document.createElement('a'); + link.href = data.url; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + return null; +} diff --git a/gitnexus/test/fixtures/lang-resolution/shape-check-integration/components/SearchBar.tsx b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/components/SearchBar.tsx new file mode 100644 index 0000000000..854a65f264 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/components/SearchBar.tsx @@ -0,0 +1,6 @@ +export function SearchBar() { + const data = fetch('/api/search').then(r => r.json()); + console.log(data.courses); + console.log(data.articles); + return null; +} diff --git a/gitnexus/test/fixtures/lang-resolution/shape-check-integration/components/UserList.tsx b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/components/UserList.tsx new file mode 100644 index 0000000000..94c8498764 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/shape-check-integration/components/UserList.tsx @@ -0,0 +1,6 @@ +export function UserList() { + const res = fetch('/api/users').then(r => r.json()); + const items = res.data; + const err = res.error; + return null; +} diff --git a/gitnexus/test/integration/resolvers/shape-check.test.ts b/gitnexus/test/integration/resolvers/shape-check.test.ts new file mode 100644 index 0000000000..52f6af4400 --- /dev/null +++ b/gitnexus/test/integration/resolvers/shape-check.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabelFull, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +describe('shape_check integration', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'shape-check-integration'), + () => {}, + ); + }, 60000); + + // --- Route & response shape extraction --- + + it('creates Route nodes with responseKeys', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const searchRoute = routes.find(r => r.name === '/api/search'); + expect(searchRoute).toBeDefined(); + expect(searchRoute!.properties.responseKeys).toBeDefined(); + expect(searchRoute!.properties.responseKeys.length).toBeGreaterThan(0); + }); + + it('extracts quoted property keys without wrapping quotes', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const searchRoute = routes.find(r => r.name === '/api/search'); + expect(searchRoute).toBeDefined(); + for (const key of searchRoute!.properties.responseKeys) { + expect(key).not.toMatch(/['"]/); + } + expect(searchRoute!.properties.responseKeys).toContain('courses'); + expect(searchRoute!.properties.responseKeys).toContain('articles'); + }); + + it('separates responseKeys from errorKeys by HTTP status', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const usersRoute = routes.find(r => r.name === '/api/users'); + expect(usersRoute).toBeDefined(); + expect(usersRoute!.properties.responseKeys).toEqual( + expect.arrayContaining(['data']), + ); + expect(usersRoute!.properties.errorKeys).toEqual( + expect.arrayContaining(['error']), + ); + }); + + // --- Consumer key extraction --- + + it('creates FETCHES edges with accessedKeys in reason', () => { + const edges = getRelationships(result, 'FETCHES'); + const searchFetch = edges.find(e => + e.sourceFilePath.includes('SearchBar') && e.target === '/api/search', + ); + expect(searchFetch).toBeDefined(); + expect(searchFetch!.rel.reason).toContain('keys:'); + const keysStr = searchFetch!.rel.reason!.match(/keys:([^|]+)/)?.[1] ?? ''; + const keys = keysStr.split(','); + expect(keys).toContain('courses'); + expect(keys).toContain('articles'); + }); + + it('does not include DOM methods in consumer accessedKeys', () => { + const edges = getRelationships(result, 'FETCHES'); + const gdprFetch = edges.find(e => + e.sourceFilePath.includes('GdprExport') && e.target === '/api/gdpr/export', + ); + expect(gdprFetch).toBeDefined(); + const keysStr = gdprFetch!.rel.reason!.match(/keys:([^|]+)/)?.[1] ?? ''; + const keys = keysStr.split(','); + expect(keys).not.toContain('appendChild'); + expect(keys).not.toContain('removeChild'); + expect(keys).not.toContain('createElement'); + expect(keys).not.toContain('click'); + expect(keys).toContain('url'); + }); + + it('captures error-path key access from consumers', () => { + const edges = getRelationships(result, 'FETCHES'); + const userFetch = edges.find(e => + e.sourceFilePath.includes('UserList') && e.target === '/api/users', + ); + expect(userFetch).toBeDefined(); + const keysStr = userFetch!.rel.reason!.match(/keys:([^|]+)/)?.[1] ?? ''; + const keys = keysStr.split(','); + expect(keys).toContain('data'); + expect(keys).toContain('error'); + }); +}); diff --git a/gitnexus/test/integration/shape-check-regression.test.ts b/gitnexus/test/integration/shape-check-regression.test.ts new file mode 100644 index 0000000000..4001efb96b --- /dev/null +++ b/gitnexus/test/integration/shape-check-regression.test.ts @@ -0,0 +1,192 @@ +/** + * Regression tests for shape_check false positives. + * + * 1. errorPathKeys exclusion: consumer accessing error-path keys (e.g. 'error') + * must land in errorPathKeys — NOT mismatched — and must NOT trigger MISMATCH status. + * + * 2. Blocklist doesn't suppress legitimate API fields: fields like 'type' and 'href' + * are blocklisted from DOM-method filtering in consumer extraction, but when a route + * actually returns them, shape_check must recognise them as valid matches. + */ +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'; + +vi.mock('../../src/storage/repo-manager.js', () => ({ + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), +})); + +// ─── Seed data ──────────────────────────────────────────────────────────────── + +const SEED: string[] = [ + // Files + `CREATE (f:File {id: 'file:app/api/orders/route.ts', name: 'route.ts', filePath: 'app/api/orders/route.ts', content: 'GET handler'})`, + `CREATE (f:File {id: 'file:app/api/links/route.ts', name: 'route.ts', filePath: 'app/api/links/route.ts', content: 'GET handler'})`, + `CREATE (f:File {id: 'file:components/OrderStatus.tsx', name: 'OrderStatus.tsx', filePath: 'components/OrderStatus.tsx', content: 'consumer'})`, + `CREATE (f:File {id: 'file:components/LinkList.tsx', name: 'LinkList.tsx', filePath: 'components/LinkList.tsx', content: 'consumer'})`, + + // ─── Route: /api/orders ────────────────────────────────────────────────── + // Success keys: [orderId, status, items] + // Error keys: [error, code] + `CREATE (r:Route {id: 'Route:/api/orders', name: '/api/orders', filePath: 'app/api/orders/route.ts', responseKeys: ['orderId', 'status', 'items'], errorKeys: ['error', 'code'], middleware: []})`, + + // ─── Route: /api/links ─────────────────────────────────────────────────── + // Returns fields that overlap with DOM property names: type, href, target + `CREATE (r:Route {id: 'Route:/api/links', name: '/api/links', filePath: 'app/api/links/route.ts', responseKeys: ['type', 'href', 'target', 'label'], errorKeys: [], middleware: []})`, + + // ─── Consumer functions ────────────────────────────────────────────────── + `CREATE (fn:Function {id: 'func:OrderStatus', name: 'OrderStatus', filePath: 'components/OrderStatus.tsx', startLine: 1, endLine: 10, isExported: true, content: 'export function OrderStatus()', description: 'Order status component'})`, + `CREATE (fn:Function {id: 'func:LinkList', name: 'LinkList', filePath: 'components/LinkList.tsx', startLine: 1, endLine: 10, isExported: true, content: 'export function LinkList()', description: 'Link list component'})`, + + // ─── Handler functions ─────────────────────────────────────────────────── + `CREATE (fn:Function {id: 'func:orders-GET', name: 'GET', filePath: 'app/api/orders/route.ts', startLine: 1, endLine: 8, isExported: true, content: 'export async function GET()', description: 'Orders GET handler'})`, + `CREATE (fn:Function {id: 'func:links-GET', name: 'GET', filePath: 'app/api/links/route.ts', startLine: 1, endLine: 8, isExported: true, content: 'export async function GET()', description: 'Links GET handler'})`, + + // ─── FETCHES edges ─────────────────────────────────────────────────────── + + // OrderStatus accesses 'orderId', 'status', and 'error' — error is in errorKeys, not responseKeys. + // This must NOT cause a MISMATCH; 'error' should appear in errorPathKeys only. + `MATCH (a:Function), (r:Route) WHERE a.id = 'func:OrderStatus' AND r.id = 'Route:/api/orders' + CREATE (a)-[:CodeRelation {type: 'FETCHES', confidence: 1.0, reason: 'fetch-url-match|keys:orderId,status,error', step: 0}]->(r)`, + + // LinkList accesses 'type', 'href', 'target', 'label' — all are legitimate route responseKeys. + // These field names overlap with DOM properties but are real API fields here. + `MATCH (a:Function), (r:Route) WHERE a.id = 'func:LinkList' AND r.id = 'Route:/api/links' + CREATE (a)-[:CodeRelation {type: 'FETCHES', confidence: 1.0, reason: 'fetch-url-match|keys:type,href,target,label', step: 0}]->(r)`, + + // ─── HANDLES_ROUTE edges ───────────────────────────────────────────────── + `MATCH (fn:Function), (r:Route) WHERE fn.id = 'func:orders-GET' AND r.id = 'Route:/api/orders' + CREATE (fn)-[:CodeRelation {type: 'HANDLES_ROUTE', confidence: 1.0, reason: 'nextjs-app-router', step: 0}]->(r)`, + `MATCH (fn:Function), (r:Route) WHERE fn.id = 'func:links-GET' AND r.id = 'Route:/api/links' + CREATE (fn)-[:CodeRelation {type: 'HANDLES_ROUTE', confidence: 1.0, reason: 'nextjs-app-router', step: 0}]->(r)`, +]; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +withTestLbugDB('shape-check-regression', (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: errorPathKeys exclusion from mismatched ────────────────── + + describe('errorPathKeys exclusion from mismatched', () => { + it('error-path key appears in errorPathKeys, not mismatched', async () => { + const result = await backend.callTool('shape_check', { route: '/api/orders' }); + + expect(result.routes).toBeDefined(); + const ordersRoute = result.routes.find((r: any) => r.route === '/api/orders'); + expect(ordersRoute).toBeDefined(); + + const consumer = ordersRoute!.consumers.find( + (c: any) => c.filePath === 'components/OrderStatus.tsx', + ); + expect(consumer).toBeDefined(); + + // 'error' is in the route's errorKeys — consumer accessing it is valid + // It must appear in errorPathKeys, NOT in mismatched + expect(consumer!.errorPathKeys).toBeDefined(); + expect(consumer!.errorPathKeys).toContain('error'); + + // 'error' must NOT appear in mismatched + if (consumer!.mismatched) { + expect(consumer!.mismatched).not.toContain('error'); + } + }); + + it('route with only error-path differences has no MISMATCH status', async () => { + const result = await backend.callTool('shape_check', { route: '/api/orders' }); + + const ordersRoute = result.routes.find((r: any) => r.route === '/api/orders'); + expect(ordersRoute).toBeDefined(); + + // All consumer keys are known (either in responseKeys or errorKeys) + // So the route must NOT be flagged as MISMATCH + expect(ordersRoute!.status).toBeUndefined(); + }); + + it('no global mismatches count when only error-path keys differ', async () => { + const result = await backend.callTool('shape_check', { route: '/api/orders' }); + + // Top-level mismatches count should be absent (0 mismatches) + expect(result.mismatches).toBeUndefined(); + }); + }); + + // ─── Test 2: blocklist doesn't suppress legitimate API fields ───────── + + describe('blocklist does not suppress legitimate API fields', () => { + it('DOM-like field names in route response are valid matches', async () => { + const result = await backend.callTool('shape_check', { route: '/api/links' }); + + expect(result.routes).toBeDefined(); + const linksRoute = result.routes.find((r: any) => r.route === '/api/links'); + expect(linksRoute).toBeDefined(); + + const consumer = linksRoute!.consumers.find( + (c: any) => c.filePath === 'components/LinkList.tsx', + ); + expect(consumer).toBeDefined(); + + // All accessed keys (type, href, target, label) are in the route's responseKeys + // None should be treated as mismatched + if (consumer!.mismatched) { + expect(consumer!.mismatched).not.toContain('type'); + expect(consumer!.mismatched).not.toContain('href'); + expect(consumer!.mismatched).not.toContain('target'); + expect(consumer!.mismatched).not.toContain('label'); + } + }); + + it('route with DOM-like fields has no MISMATCH status', async () => { + const result = await backend.callTool('shape_check', { route: '/api/links' }); + + const linksRoute = result.routes.find((r: any) => r.route === '/api/links'); + expect(linksRoute).toBeDefined(); + + // No mismatches — all consumer keys match route responseKeys + expect(linksRoute!.status).toBeUndefined(); + }); + + it('no errorPathKeys when all accessed keys are in responseKeys', async () => { + const result = await backend.callTool('shape_check', { route: '/api/links' }); + + const linksRoute = result.routes.find((r: any) => r.route === '/api/links'); + const consumer = linksRoute!.consumers.find( + (c: any) => c.filePath === 'components/LinkList.tsx', + ); + expect(consumer).toBeDefined(); + + // All keys are in responseKeys (not errorKeys), so no errorPathKeys + expect(consumer!.errorPathKeys).toBeUndefined(); + }); + }); + +}, { + seed: SEED, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-shape-regression', + path: '/test/shape-regression', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 4, nodes: 8, communities: 0, processes: 0 }, + }, + ]); + + const backend = new LocalBackend(); + await backend.init(); + (handle as any)._backend = backend; + }, +}); diff --git a/gitnexus/test/unit/shape-check.test.ts b/gitnexus/test/unit/shape-check.test.ts new file mode 100644 index 0000000000..019de0d2de --- /dev/null +++ b/gitnexus/test/unit/shape-check.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { extractResponseShapes } from '../../src/core/ingestion/route-extractors/response-shapes.js'; +import { extractConsumerAccessedKeys } from '../../src/core/ingestion/call-processor.js'; + +describe('extractResponseShapes', () => { + it('extracts unquoted keys from .json() call', () => { + const content = `return NextResponse.json({ data: [], pagination: {} });`; + const result = extractResponseShapes(content); + expect(result.responseKeys).toContain('data'); + expect(result.responseKeys).toContain('pagination'); + }); + + it('extracts single-quoted property keys from .json() call', () => { + const content = `return NextResponse.json({ 'courses': coursesData, 'articles': articlesData });`; + const result = extractResponseShapes(content); + expect(result.responseKeys).toContain('courses'); + expect(result.responseKeys).toContain('articles'); + // Must not contain quotes + for (const key of result.responseKeys!) { + expect(key).not.toMatch(/['"]/); + } + }); + + it('extracts double-quoted property keys from .json() call', () => { + const content = `return NextResponse.json({ "items": data, "count": total });`; + const result = extractResponseShapes(content); + expect(result.responseKeys).toContain('items'); + expect(result.responseKeys).toContain('count'); + }); + + it('extracts backtick-quoted property keys from .json() call', () => { + const content = 'return NextResponse.json({ `users`: data, `total`: count });'; + const result = extractResponseShapes(content); + expect(result.responseKeys).toContain('users'); + expect(result.responseKeys).toContain('total'); + }); + + it('classifies error keys by status code', () => { + const content = ` + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + `; + const result = extractResponseShapes(content); + expect(result.errorKeys).toContain('error'); + expect(result.responseKeys).toBeUndefined(); + }); + + it('separates success and error keys from same handler', () => { + const content = ` + if (bad) return NextResponse.json({ error: 'fail', details: 'x' }, { status: 400 }); + return NextResponse.json({ data: items, total: count }); + `; + const result = extractResponseShapes(content); + expect(result.responseKeys).toEqual(expect.arrayContaining(['data', 'total'])); + expect(result.errorKeys).toEqual(expect.arrayContaining(['error', 'details'])); + }); +}); + +describe('extractConsumerAccessedKeys', () => { + it('extracts destructured keys from .json()', () => { + const content = `const { data, pagination } = await res.json();`; + const keys = extractConsumerAccessedKeys(content); + expect(keys).toContain('data'); + expect(keys).toContain('pagination'); + }); + + it('extracts property access on response variables', () => { + const content = ` + const data = await fetch('/api/test').then(r => r.json()); + console.log(data.items); + console.log(data.total); + `; + const keys = extractConsumerAccessedKeys(content); + expect(keys).toContain('items'); + expect(keys).toContain('total'); + }); + + it('filters out common response methods', () => { + const content = ` + const items = data.map(x => x.name); + data.forEach(item => process(item)); + const len = data.length; + `; + const keys = extractConsumerAccessedKeys(content); + expect(keys).not.toContain('map'); + expect(keys).not.toContain('forEach'); + expect(keys).not.toContain('length'); + }); + + it('filters out DOM manipulation methods', () => { + const content = ` + const { url } = await response.json(); + const link = document.createElement('a'); + link.href = url; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + `; + const keys = extractConsumerAccessedKeys(content); + expect(keys).not.toContain('appendChild'); + expect(keys).not.toContain('removeChild'); + expect(keys).not.toContain('createElement'); + expect(keys).not.toContain('click'); + }); + + it('still captures real response keys alongside DOM code', () => { + const content = ` + const data = await res.json(); + const url = data.downloadUrl; + const link = document.createElement('a'); + document.body.appendChild(link); + document.body.removeChild(link); + `; + const keys = extractConsumerAccessedKeys(content); + expect(keys).toContain('downloadUrl'); + expect(keys).not.toContain('appendChild'); + expect(keys).not.toContain('removeChild'); + }); + + it('does not blocklist legitimate API field names', () => { + const content = ` + const data = await res.json(); + console.log(data.type); + console.log(data.href); + console.log(data.target); + console.log(data.style); + `; + const keys = extractConsumerAccessedKeys(content); + expect(keys).toContain('type'); + expect(keys).toContain('href'); + expect(keys).toContain('target'); + expect(keys).toContain('style'); + }); +});