diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 15f09ca6e..61d9a74a2 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -11,7 +11,7 @@ import { EMPTY_INDEX } from './import-resolvers/utils.js'; import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted, processNextjsFetchRoutes, extractFetchCallsFromFiles, seedCrossFileReceiverTypes, buildImportedReturnTypes, buildImportedRawReturnTypes, type ExportedTypeMap, buildExportedTypeMapFromGraph } from './call-processor.js'; import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js'; import { phpFileToRouteURL } from './route-extractors/php.js'; -import { extractResponseShapes } from './route-extractors/response-shapes.js'; +import { extractResponseShapes, extractPHPResponseShapes } from './route-extractors/response-shapes.js'; import { extractMiddlewareChain } from './route-extractors/middleware.js'; import { generateId } from '../../lib/utils.js'; import type { ExtractedFetchCall, ExtractedRoute, ExtractedDecoratorRoute, ExtractedToolDef } from './workers/parse-worker.js'; @@ -1113,7 +1113,7 @@ export const runPipelineFromRepo = async ( const content = handlerContents.get(handlerPath); const { responseKeys, errorKeys } = content - ? extractResponseShapes(content) + ? (handlerPath.endsWith(".php") ? extractPHPResponseShapes(content) : extractResponseShapes(content)) : { responseKeys: undefined, errorKeys: undefined }; const mwResult = content ? extractMiddlewareChain(content) : undefined; diff --git a/gitnexus/src/core/ingestion/route-extractors/response-shapes.ts b/gitnexus/src/core/ingestion/route-extractors/response-shapes.ts index 2de2b9d9d..8d47daf4e 100644 --- a/gitnexus/src/core/ingestion/route-extractors/response-shapes.ts +++ b/gitnexus/src/core/ingestion/route-extractors/response-shapes.ts @@ -1,57 +1,57 @@ /** * Response shape extraction from route handler file content. - * Detects .json() calls, extracts top-level keys, and classifies by HTTP status code. + * Detects .json() calls (JS/TS) and json_encode() calls (PHP), + * extracts top-level keys, and classifies by HTTP status code. */ +/** Return the status code (group 1) from the last match, or undefined. */ +function lastMatchGroup(text: string, pattern: RegExp): number | undefined { + const matches = [...text.matchAll(pattern)]; + if (matches.length === 0) return undefined; + return parseInt(matches[matches.length - 1][1], 10); +} + +/** Build the {responseKeys, errorKeys} result, deduplicating and omitting empty. */ +function buildShapeResult( + successKeys: string[], + errKeys: string[], +): { responseKeys?: string[]; errorKeys?: string[] } { + return { + ...(successKeys.length > 0 ? { responseKeys: [...new Set(successKeys)] } : {}), + ...(errKeys.length > 0 ? { errorKeys: [...new Set(errKeys)] } : {}), + }; +} + /** * Detect an HTTP status code associated with a .json() call. - * Looks for three patterns: - * 1. `.status(N).json(` — Express style (look backwards from .json match) - * 2. `.json({...}, { status: N })` — NextResponse style (look after closing brace of first arg) - * 3. `new Response(JSON.stringify({...}), { status: N })` — raw Response constructor - * - * Returns the numeric status code, or undefined if none found. */ export function detectStatusCode(content: string, jsonMatchPos: number, closingBracePos: number): number | undefined { - // Pattern 1: .status(N).json( — look backwards from .json - // Check the ~200 chars before .json for .status(NNN) (generous window for chained calls) const lookbackStart = Math.max(0, jsonMatchPos - 200); const before = content.slice(lookbackStart, jsonMatchPos); const statusChainMatch = before.match(/\.status\s*\(\s*(\d{3})\s*\)\s*$/); if (statusChainMatch) { return parseInt(statusChainMatch[1], 10); } - - // Pattern 2: .json({...}, { status: N }) — look after closing brace for second arg if (closingBracePos > 0) { - // After the first arg's closing brace, look for ", { status: N" within ~100 chars const afterFirstArg = content.slice(closingBracePos + 1, closingBracePos + 150); const secondArgMatch = afterFirstArg.match(/^\s*,\s*\{[^}]*status\s*:\s*(\d{3})/); if (secondArgMatch) { return parseInt(secondArgMatch[1], 10); } } - - // Pattern 3: new Response(JSON.stringify({...}), { status: N }) — look before .json for JSON.stringify - // This is a less common pattern; we check if the .json is actually part of JSON.stringify - // by looking for "new Response" further back const extendedBefore = content.slice(Math.max(0, jsonMatchPos - 300), jsonMatchPos); if (/new\s+Response\s*\(\s*JSON\s*\.stringify\s*$/.test(extendedBefore) && closingBracePos > 0) { - // Look for ), { status: N }) after the stringify's closing paren const afterStringify = content.slice(closingBracePos + 1, closingBracePos + 200); const respStatusMatch = afterStringify.match(/^\s*\)\s*,\s*\{[^}]*status\s*:\s*(\d{3})/); if (respStatusMatch) { return parseInt(respStatusMatch[1], 10); } } - return undefined; } /** - * Extract response shapes from handler file content. - * Finds all .json({...}) calls, extracts top-level keys using brace-depth counting, - * and classifies into success (responseKeys) vs error (errorKeys) by HTTP status code. + * Extract response shapes from JS/TS handler file content. */ export function extractResponseShapes(content: string): { responseKeys?: string[]; errorKeys?: string[] } { const successKeys: string[] = []; @@ -99,8 +99,119 @@ export function extractResponseShapes(content: string): { responseKeys?: string[ successKeys.push(...callKeys); } } - return { - ...(successKeys.length > 0 ? { responseKeys: [...new Set(successKeys)] } : {}), - ...(errKeys.length > 0 ? { errorKeys: [...new Set(errKeys)] } : {}), - }; + return buildShapeResult(successKeys, errKeys); +} + +/** + * Find the last exit/die boundary in a string. + * Matches: exit; exit(N); die; die('msg'); die($var); + * Returns the index AFTER the boundary. + */ +function findLastExitBoundary(text: string): number { + const pattern = /\b(exit|die)\s*(\([^)]*\))?\s*;/g; + let lastEnd = -1; + let m; + while ((m = pattern.exec(text)) !== null) { + lastEnd = m.index + m[0].length; + } + return lastEnd; +} + +function detectPHPStatusCode(content: string, jsonEncodePos: number): number | undefined { + const lookbackStart = Math.max(0, jsonEncodePos - 300); + let before = content.slice(lookbackStart, jsonEncodePos); + const boundaryEnd = findLastExitBoundary(before); + if (boundaryEnd !== -1) { + before = before.slice(boundaryEnd); + } + return lastMatchGroup(before, /http_response_code\s*\(\s*(\d{3})\s*\)/g) + ?? lastMatchGroup(before, /header\s*\(\s*['"]HTTP\/[\d.]+\s+(\d{3})/g) + // CGI/FastCGI format + ?? lastMatchGroup(before, /header\s*\(\s*['"]Status:\s*(\d{3})/g); +} + +function findMatchingBracket(content: string, openPos: number, open: string, close: string): number { + let depth = 0; + let inString: string | null = null; + for (let j = openPos; j < content.length; j++) { + const ch = content[j]; + if (inString) { + if (ch === '\\') { j++; continue; } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { inString = ch; continue; } + if (ch === open) { depth++; continue; } + if (ch === close) { depth--; if (depth === 0) return j; continue; } + } + return -1; +} + +function extractPHPArrayKeys(arrayContent: string): string[] { + const keys: string[] = []; + let depth = 0; + let inString: string | null = null; + const topLevelRanges: Array<[number, number]> = []; + let rangeStart = 0; + for (let i = 0; i < arrayContent.length; i++) { + const ch = arrayContent[i]; + if (inString) { + if (ch === '\\') { i++; continue; } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { inString = ch; continue; } + if (ch === '[' || ch === '(' || ch === '{') { + if (depth === 0) topLevelRanges.push([rangeStart, i]); + depth++; + } else if (ch === ']' || ch === ')' || ch === '}') { + depth--; + if (depth === 0) rangeStart = i + 1; + } + } + if (depth === 0) topLevelRanges.push([rangeStart, arrayContent.length]); + for (const [start, end] of topLevelRanges) { + const segment = arrayContent.slice(start, end); + const localPattern = /(['"])([a-zA-Z_][a-zA-Z0-9_]*)\1\s*=>/g; + let m; + while ((m = localPattern.exec(segment)) !== null) { + keys.push(m[2]); + } + } + return keys; +} + +export function extractPHPResponseShapes(content: string): { responseKeys?: string[]; errorKeys?: string[] } { + const successKeys: string[] = []; + const errKeys: string[] = []; + const jsonEncodePattern = /json_encode\s*\(/g; + let match; + while ((match = jsonEncodePattern.exec(content)) !== null) { + const matchPos = match.index; + const startIdx = matchPos + match[0].length; + let i = startIdx; + while (i < content.length && /\s/.test(content[i])) i++; + if (i >= content.length) continue; + let arrayEnd = -1; + const openChar = content[i]; + if (openChar === '[') { + arrayEnd = findMatchingBracket(content, i, '[', ']'); + } else if (content.slice(i, i + 6) === 'array(') { + i += 5; + arrayEnd = findMatchingBracket(content, i, '(', ')'); + } else { + continue; + } + if (arrayEnd === -1) continue; + const arrayContent = content.slice(i + 1, arrayEnd); + const callKeys = extractPHPArrayKeys(arrayContent); + if (callKeys.length === 0) continue; + const status = detectPHPStatusCode(content, matchPos); + if (status !== undefined && status >= 400) { + errKeys.push(...callKeys); + } else { + successKeys.push(...callKeys); + } + } + return buildShapeResult(successKeys, errKeys); } diff --git a/gitnexus/test/fixtures/lang-resolution/php-response-shapes/api/items.php b/gitnexus/test/fixtures/lang-resolution/php-response-shapes/api/items.php new file mode 100644 index 000000000..d4110c8c0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-response-shapes/api/items.php @@ -0,0 +1,18 @@ + 'Method not allowed'], JSON_UNESCAPED_UNICODE); + exit; +} + +if (!is_logged_in()) { + http_response_code(401); + echo json_encode(['error' => 'Unauthorized', 'code' => 'AUTH_REQUIRED'], JSON_UNESCAPED_UNICODE); + exit; +} + +$items = get_items(); +echo json_encode(['data' => $items, 'total' => count($items)], JSON_UNESCAPED_UNICODE); diff --git a/gitnexus/test/fixtures/lang-resolution/php-response-shapes/api/submit.php b/gitnexus/test/fixtures/lang-resolution/php-response-shapes/api/submit.php new file mode 100644 index 000000000..71c18fc7d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-response-shapes/api/submit.php @@ -0,0 +1,24 @@ + 'POST only']); + die(); +} + +$data = json_decode(file_get_contents('php://input'), true); + +if (empty($data['name'])) { + http_response_code(400); + echo json_encode(['error' => 'Validation failed', 'field' => 'name']); + exit(1); +} + +try { + $id = save_item($data); + echo json_encode(['ok' => true, 'id' => $id, 'created_at' => date('c')]); +} catch (PDOException $e) { + http_response_code(500); + echo json_encode(['error' => 'Database error']); +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-response-shapes/includes/auth.php b/gitnexus/test/fixtures/lang-resolution/php-response-shapes/includes/auth.php new file mode 100644 index 000000000..06c3505f1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-response-shapes/includes/auth.php @@ -0,0 +1,4 @@ + { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-response-shapes'), + () => {}, + ); + }, 60000); + + it('creates Route nodes for PHP endpoints', () => { + const routes = getNodesByLabel(result, 'Route'); + expect(routes).toContain('/api/items'); + expect(routes).toContain('/api/submit'); + }); + + it('extracts responseKeys from json_encode success path (items.php)', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const items = routes.find(r => r.name === '/api/items'); + expect(items).toBeDefined(); + expect(items!.properties.responseKeys).toEqual( + expect.arrayContaining(['data', 'total']), + ); + }); + + it('extracts errorKeys from json_encode with http_response_code >= 400 (items.php)', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const items = routes.find(r => r.name === '/api/items'); + expect(items).toBeDefined(); + expect(items!.properties.errorKeys).toEqual( + expect.arrayContaining(['error']), + ); + }); + + it('keeps success and error keys separate (no cross-contamination)', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const items = routes.find(r => r.name === '/api/items'); + expect(items).toBeDefined(); + const successKeys = new Set(items!.properties.responseKeys ?? []); + const errorKeys = new Set(items!.properties.errorKeys ?? []); + expect(successKeys.has('error')).toBe(false); + expect(errorKeys.has('data')).toBe(false); + expect(errorKeys.has('total')).toBe(false); + }); + + it('extracts responseKeys from submit.php success path', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const submit = routes.find(r => r.name === '/api/submit'); + expect(submit).toBeDefined(); + expect(submit!.properties.responseKeys).toEqual( + expect.arrayContaining(['ok', 'id', 'created_at']), + ); + }); + + it('extracts errorKeys from submit.php error paths', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const submit = routes.find(r => r.name === '/api/submit'); + expect(submit).toBeDefined(); + expect(submit!.properties.errorKeys).toEqual( + expect.arrayContaining(['error']), + ); + }); + + it('respects exit(N) and die() as boundaries in submit.php', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const submit = routes.find(r => r.name === '/api/submit'); + expect(submit).toBeDefined(); + const successKeys = new Set(submit!.properties.responseKeys ?? []); + expect(successKeys.has('ok')).toBe(true); + expect(successKeys.has('id')).toBe(true); + expect(successKeys.has('error')).toBe(false); + }); + + it('creates HANDLES_ROUTE edges from PHP files to Route nodes', () => { + const edges = getRelationships(result, 'HANDLES_ROUTE'); + const itemsHandler = edges.find(e => e.target === '/api/items'); + expect(itemsHandler).toBeDefined(); + expect(itemsHandler!.sourceFilePath).toContain('api/items.php'); + const submitHandler = edges.find(e => e.target === '/api/submit'); + expect(submitHandler).toBeDefined(); + expect(submitHandler!.sourceFilePath).toContain('api/submit.php'); + }); +}); diff --git a/gitnexus/test/unit/route-tool-detection.test.ts b/gitnexus/test/unit/route-tool-detection.test.ts index df2d70b43..105cfdbff 100644 --- a/gitnexus/test/unit/route-tool-detection.test.ts +++ b/gitnexus/test/unit/route-tool-detection.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect } from 'vitest'; import { nextjsFileToRouteURL, normalizeFetchURL, routeMatches } from '../../src/core/ingestion/route-extractors/nextjs.js'; import { phpFileToRouteURL } from '../../src/core/ingestion/route-extractors/php.js'; import { extractMiddlewareChain } from '../../src/core/ingestion/route-extractors/middleware.js'; -import { detectStatusCode, extractResponseShapes } from '../../src/core/ingestion/route-extractors/response-shapes.js'; +import { detectStatusCode, extractResponseShapes, extractPHPResponseShapes } from '../../src/core/ingestion/route-extractors/response-shapes.js'; // --------------------------------------------------------------------------- // Next.js route extractor @@ -468,3 +468,124 @@ describe('error response shape separation', () => { expect(shapes.errorKeys).toBeUndefined(); }); }); + +describe('PHP response shape extraction', () => { + it('extracts keys from short array syntax', () => { + const content = `echo json_encode(['success' => true, 'data' => $items], JSON_UNESCAPED_UNICODE);`; + const shapes = extractPHPResponseShapes(content); + expect(shapes.responseKeys).toEqual(['success', 'data']); + expect(shapes.errorKeys).toBeUndefined(); + }); + + it('extracts keys from long array syntax', () => { + const content = `echo json_encode(array('ok' => true, 'count' => $n));`; + const shapes = extractPHPResponseShapes(content); + expect(shapes.responseKeys).toEqual(['ok', 'count']); + }); + + it('classifies error responses by http_response_code', () => { + const content = ` + http_response_code(401); + echo json_encode(['error' => 'Unauthorized'], JSON_UNESCAPED_UNICODE); + `; + const shapes = extractPHPResponseShapes(content); + expect(shapes.responseKeys).toBeUndefined(); + expect(shapes.errorKeys).toEqual(['error']); + }); + + it('separates success and error responses', () => { + const content = ` + header('Content-Type: application/json'); + if (!is_logged_in()) { + http_response_code(401); + echo json_encode(['error' => 'Not logged in'], JSON_UNESCAPED_UNICODE); + exit; + } + echo json_encode(['ok' => true, 'new_status' => $status], JSON_UNESCAPED_UNICODE); + `; + const shapes = extractPHPResponseShapes(content); + expect(shapes.responseKeys).toEqual(['ok', 'new_status']); + expect(shapes.errorKeys).toEqual(['error']); + }); + + it('handles multiple error status codes with exit boundaries', () => { + const content = ` + if (!$user) { + http_response_code(401); + echo json_encode(['error' => 'Unauthorized'], JSON_UNESCAPED_UNICODE); + exit; + } + if (!$valid) { + http_response_code(400); + echo json_encode(['error' => 'Invalid data', 'field' => 'name'], JSON_UNESCAPED_UNICODE); + exit; + } + echo json_encode(['ok' => true, 'id' => $id], JSON_UNESCAPED_UNICODE); + `; + const shapes = extractPHPResponseShapes(content); + expect(shapes.responseKeys).toEqual(['ok', 'id']); + expect(shapes.errorKeys).toEqual(['error', 'field']); + }); + + it('detects status from header() pattern', () => { + const content = ` + header('HTTP/1.1 403 Forbidden'); + echo json_encode(['error' => 'Forbidden', 'message' => 'Access denied']); + `; + const shapes = extractPHPResponseShapes(content); + expect(shapes.errorKeys).toEqual(['error', 'message']); + }); + + it('skips json_encode with variable argument', () => { + const content = `echo json_encode($data, JSON_UNESCAPED_UNICODE);`; + const shapes = extractPHPResponseShapes(content); + expect(shapes.responseKeys).toBeUndefined(); + expect(shapes.errorKeys).toBeUndefined(); + }); + + it('extracts only top-level keys from nested arrays', () => { + const content = `echo json_encode(['data' => ['nested' => true], 'total' => $count]);`; + const shapes = extractPHPResponseShapes(content); + expect(shapes.responseKeys).toEqual(['data', 'total']); + }); + + it('handles json_encode with flags after array', () => { + const content = `echo json_encode(['export' => $data], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);`; + const shapes = extractPHPResponseShapes(content); + expect(shapes.responseKeys).toEqual(['export']); + }); + + it('recognizes exit(N) as a boundary', () => { + const content = ` + http_response_code(401); + echo json_encode(['error' => 'Unauthorized']); + exit(0); + echo json_encode(['ok' => true, 'data' => $result]); + `; + const shapes = extractPHPResponseShapes(content); + expect(shapes.responseKeys).toEqual(['ok', 'data']); + expect(shapes.errorKeys).toEqual(['error']); + }); + + it('recognizes die("msg") as a boundary', () => { + const content = ` + http_response_code(500); + echo json_encode(['error' => 'DB error']); + die('Fatal'); + echo json_encode(['items' => $list]); + `; + const shapes = extractPHPResponseShapes(content); + expect(shapes.responseKeys).toEqual(['items']); + expect(shapes.errorKeys).toEqual(['error']); + }); + + it('detects CGI Status header format', () => { + const content = ` + header('Status: 404 Not Found'); + echo json_encode(['error' => 'Not found', 'code' => 'MISSING']); + `; + const shapes = extractPHPResponseShapes(content); + expect(shapes.errorKeys).toEqual(['error', 'code']); + expect(shapes.responseKeys).toBeUndefined(); + }); +});