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:
marxo126 2026-03-25 09:27:53 +01:00 • committed by GitHub
parent c68d7975e6
commit 4bc4815bd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 402 additions and 28 deletions

View file

@ -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;

View file

@ -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);
}

View 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);

View 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']);
}

View file

@ -0,0 +1,4 @@
<?php
function is_logged_in() {
return isset($_SESSION['user']);
}

View file

@ -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');
});
});

View file

@ -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();
});
});