mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
feat(extractors): add PHP HTTP consumer detection (#993)
Extend the PHP tree-sitter plugin to emit consumer HttpDetections for three common PHP HTTP call shapes, matching Node plugin parity: - Laravel HTTP client: Http::get/post/put/delete/patch($url) - Guzzle / generic: $client->get/post/...($url) - file_get_contents($url) when the URL is absolute http(s):// String-literal URLs only. Paths built via binary concatenation (`$base . '/path'`), sprintf, or config lookups are intentionally deferred — they need constant-folding of the enclosing scope to be useful and are tracked as follow-up work. Refs #992 Co-authored-by: Jonas Vanderhaegen <jonasvanderh+claude.ai@gmail.com>
This commit is contained in:
parent
c24bcc3bf1
commit
06967e2b66
2 changed files with 222 additions and 20 deletions
|
|
@ -3,33 +3,92 @@ import {
|
|||
compilePatterns,
|
||||
runCompiledPatterns,
|
||||
unquoteLiteral,
|
||||
type CompiledPatterns,
|
||||
type LanguagePatterns,
|
||||
type PatternSpec,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
||||
|
||||
/**
|
||||
* PHP HTTP plugin — Laravel `Route::get/post/...` declarations.
|
||||
* PHP HTTP plugin.
|
||||
*
|
||||
* Providers:
|
||||
* - Laravel `Route::get/post/...`
|
||||
*
|
||||
* Consumers (string-literal URLs only):
|
||||
* - Laravel HTTP client: `Http::get/post/put/delete/patch($url)`
|
||||
* - Guzzle / generic object method: `$client->get/post/...($url)`
|
||||
* - `file_get_contents($url)`
|
||||
*
|
||||
* The pipeline already uses `PHP.php_only` for ingesting plain `.php`
|
||||
* files (see `core/tree-sitter/parser-loader.ts`), and we do the same
|
||||
* here so Laravel route files are parsed with the right grammar dialect.
|
||||
*
|
||||
* Scope notes: consumer patterns match string literals only. URLs built
|
||||
* via binary concatenation (`$base . '/path'`), `sprintf`, or config
|
||||
* lookup (`config('services.foo.base').'/path'`) are intentionally left
|
||||
* for a follow-up — they require constant-folding the surrounding
|
||||
* scope to be meaningful.
|
||||
*/
|
||||
|
||||
const LARAVEL_PATTERNS = compilePatterns({
|
||||
name: 'php-laravel',
|
||||
language: PHP.php_only,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(scoped_call_expression
|
||||
scope: (name) @scope (#eq? @scope "Route")
|
||||
name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
|
||||
arguments: (arguments . (argument (string) @path)))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
const LARAVEL_ROUTE_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(scoped_call_expression
|
||||
scope: (name) @scope (#eq? @scope "Route")
|
||||
name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
|
||||
arguments: (arguments . (argument (string) @path)))
|
||||
`,
|
||||
};
|
||||
|
||||
const HTTP_FACADE_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(scoped_call_expression
|
||||
scope: (name) @scope (#eq? @scope "Http")
|
||||
name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
|
||||
arguments: (arguments . (argument (string) @path)))
|
||||
`,
|
||||
};
|
||||
|
||||
const GUZZLE_MEMBER_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(member_call_expression
|
||||
name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
|
||||
arguments: (arguments . (argument (string) @path)))
|
||||
`,
|
||||
};
|
||||
|
||||
const FILE_GET_CONTENTS_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(function_call_expression
|
||||
function: (name) @fn (#eq? @fn "file_get_contents")
|
||||
arguments: (arguments . (argument (string) @path)))
|
||||
`,
|
||||
};
|
||||
|
||||
interface PhpPatternBundle {
|
||||
laravelRoute: CompiledPatterns<Record<string, never>>;
|
||||
httpFacade: CompiledPatterns<Record<string, never>>;
|
||||
guzzleMember: CompiledPatterns<Record<string, never>>;
|
||||
fileGetContents: CompiledPatterns<Record<string, never>>;
|
||||
}
|
||||
|
||||
const mk = (spec: PatternSpec<Record<string, never>>, suffix: string) =>
|
||||
compilePatterns({
|
||||
name: `php-${suffix}`,
|
||||
language: PHP.php_only,
|
||||
patterns: [spec],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
const PHP_PATTERNS: PhpPatternBundle = {
|
||||
laravelRoute: mk(LARAVEL_ROUTE_SPEC, 'laravel-route'),
|
||||
httpFacade: mk(HTTP_FACADE_SPEC, 'http-facade'),
|
||||
guzzleMember: mk(GUZZLE_MEMBER_SPEC, 'guzzle-member'),
|
||||
fileGetContents: mk(FILE_GET_CONTENTS_SPEC, 'file-get-contents'),
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the inner text of a PHP `string` node. The tree-sitter-php
|
||||
|
|
@ -39,11 +98,8 @@ const LARAVEL_PATTERNS = compilePatterns({
|
|||
* child nodes.
|
||||
*/
|
||||
function phpStringText(node: import('tree-sitter').SyntaxNode): string | null {
|
||||
// Most single-quoted strings expose their inner content through the
|
||||
// full node text (including quotes), which unquoteLiteral strips.
|
||||
const direct = unquoteLiteral(node.text);
|
||||
if (direct !== null && direct !== node.text) return direct;
|
||||
// Fall back to child string_content / string_value node if present.
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'string_content' || child.type === 'string_value') {
|
||||
return child.text;
|
||||
|
|
@ -52,13 +108,32 @@ function phpStringText(node: import('tree-sitter').SyntaxNode): string | null {
|
|||
return direct;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP client helpers (`Http::`, Guzzle) are almost always called with
|
||||
* a path relative to a configured base URL, or a full URL. File paths
|
||||
* are rare. Accept both relative (`/api/...`) and absolute (`http(s)://`).
|
||||
*/
|
||||
function isHttpClientPath(path: string): boolean {
|
||||
return path.startsWith('/') || path.startsWith('http://') || path.startsWith('https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* `file_get_contents` is used for both HTTP and filesystem reads. Only
|
||||
* emit a consumer contract when the URL is an absolute HTTP(S) URL to
|
||||
* avoid false positives for local file paths and stream wrappers
|
||||
* (`php://input`, `file://`, `data:`, ...).
|
||||
*/
|
||||
function isHttpUrlLiteral(path: string): boolean {
|
||||
return path.startsWith('http://') || path.startsWith('https://');
|
||||
}
|
||||
|
||||
export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'php-http',
|
||||
language: PHP.php_only,
|
||||
scan(tree) {
|
||||
const out: HttpDetection[] = [];
|
||||
|
||||
for (const match of runCompiledPatterns(LARAVEL_PATTERNS, tree)) {
|
||||
for (const match of runCompiledPatterns(PHP_PATTERNS.laravelRoute, tree)) {
|
||||
const methodNode = match.captures.method;
|
||||
const pathNode = match.captures.path;
|
||||
if (!methodNode || !pathNode) continue;
|
||||
|
|
@ -74,6 +149,53 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
});
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(PHP_PATTERNS.httpFacade, tree)) {
|
||||
const methodNode = match.captures.method;
|
||||
const pathNode = match.captures.path;
|
||||
if (!methodNode || !pathNode) continue;
|
||||
const path = phpStringText(pathNode);
|
||||
if (path === null || !isHttpClientPath(path)) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'laravel-http',
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(PHP_PATTERNS.guzzleMember, tree)) {
|
||||
const methodNode = match.captures.method;
|
||||
const pathNode = match.captures.path;
|
||||
if (!methodNode || !pathNode) continue;
|
||||
const path = phpStringText(pathNode);
|
||||
if (path === null || !isHttpClientPath(path)) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'guzzle',
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(PHP_PATTERNS.fileGetContents, tree)) {
|
||||
const pathNode = match.captures.path;
|
||||
if (!pathNode) continue;
|
||||
const path = phpStringText(pathNode);
|
||||
if (path === null || !isHttpUrlLiteral(path)) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'file-get-contents',
|
||||
method: 'GET',
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -517,6 +517,86 @@ Route::delete('/users/{id}', [UserController::class, 'destroy']);
|
|||
});
|
||||
});
|
||||
|
||||
describe('consumer extraction — PHP', () => {
|
||||
it('extracts Laravel Http facade calls', async () => {
|
||||
const dir = path.join(tmpDir, 'php-http-facade');
|
||||
fs.mkdirSync(path.join(dir, 'app'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'app/Client.php'),
|
||||
`<?php
|
||||
use Illuminate\\Support\\Facades\\Http;
|
||||
|
||||
class Client {
|
||||
public function run() {
|
||||
Http::get('/api/users');
|
||||
Http::post('/api/orders/42');
|
||||
Http::delete('/api/users/7');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('extracts Guzzle $client->method() calls', async () => {
|
||||
const dir = path.join(tmpDir, 'php-guzzle');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/ApiClient.php'),
|
||||
`<?php
|
||||
use GuzzleHttp\\Client;
|
||||
|
||||
class ApiClient {
|
||||
public function run(Client $client) {
|
||||
$client->get('/api/health');
|
||||
$client->post('/api/orders/42');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/api/health')).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('extracts file_get_contents HTTP calls', async () => {
|
||||
const dir = path.join(tmpDir, 'php-fgc');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/fetch.php'),
|
||||
`<?php
|
||||
function fetchRemote() {
|
||||
$data = file_get_contents('https://example.test/api/items/1');
|
||||
$local = file_get_contents('/tmp/local-file.txt');
|
||||
return $data;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/api/items/{param}')).toBeDefined();
|
||||
// file paths and stream wrappers must not emit consumer contracts
|
||||
expect(consumers.find((c) => c.meta.path === '/tmp/local-file.txt')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider extraction — FastAPI', () => {
|
||||
it('extracts FastAPI @app.get decorator patterns', async () => {
|
||||
const dir = path.join(tmpDir, 'fastapi');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue