mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
feat: PHP response shape extraction for json_encode patterns (#502)
* feat: add PHP response shape extraction for json_encode patterns
Adds extractPHPResponseShapes() to detect response keys from PHP
json_encode() calls with associative array literals. Supports:
- Short array syntax: json_encode(['key' => value])
- Long array syntax: json_encode(array('key' => value))
- Error classification via http_response_code() and header() status
- exit;/die; boundary detection to prevent cross-block status leaking
- Nested array filtering (only top-level keys extracted)
Pipeline integration dispatches PHP files to the new extractor.
Verified on collector project: 10 PHP routes now show responseKeys.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review — exit boundary, die; offset, CGI Status header
- Replace lastIndexOf('exit;')/lastIndexOf('die;') with regex that
matches exit(N), exit(0), die('msg'), die($var) as boundaries
- Fixes die; off-by-one (was slicing at +5 for a 4-char keyword)
- Add header('Status: NNN') CGI/FastCGI format detection
- Add 3 regression tests for the fixed bugs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: extract shared helpers, remove duplicate test block
- Extract lastMatchGroup() and buildShapeResult() to eliminate repeated
patterns in both JS/TS and PHP extractors
- Simplify detectPHPStatusCode to use ?? chaining with lastMatchGroup
- Remove duplicate 9-test PHP describe block (kept the 12-test version)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add PHP response shape integration tests
Adds a PHP fixture (api/items.php, api/submit.php) with multiple
json_encode patterns and a pipeline integration test verifying:
- Route nodes created for PHP endpoints
- responseKeys/errorKeys correctly extracted and separated
- exit(N)/die() boundaries respected
- HANDLES_ROUTE edges point to correct PHP handler files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c68d7975e6
commit
4bc4815bd2
7 changed files with 402 additions and 28 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
18
gitnexus/test/fixtures/lang-resolution/php-response-shapes/api/items.php
vendored
Normal file
18
gitnexus/test/fixtures/lang-resolution/php-response-shapes/api/items.php
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => '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);
|
||||
24
gitnexus/test/fixtures/lang-resolution/php-response-shapes/api/submit.php
vendored
Normal file
24
gitnexus/test/fixtures/lang-resolution/php-response-shapes/api/submit.php
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Status: 405 Method Not Allowed');
|
||||
echo json_encode(['error' => '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']);
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/php-response-shapes/includes/auth.php
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/php-response-shapes/includes/auth.php
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?php
|
||||
function is_logged_in() {
|
||||
return isset($_SESSION['user']);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Integration test for PHP response shape extraction.
|
||||
*
|
||||
* Runs the full pipeline on a PHP fixture with json_encode() calls
|
||||
* and verifies that Route nodes have correct responseKeys/errorKeys.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES, getNodesByLabel, getNodesByLabelFull, getRelationships,
|
||||
runPipelineFromRepo, type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('PHP response shape extraction (pipeline)', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue