diff --git a/gitnexus/src/core/group/extractors/http-patterns/php.ts b/gitnexus/src/core/group/extractors/http-patterns/php.ts index ae91c141b..c1c40a09c 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/php.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/php.ts @@ -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>); +const LARAVEL_ROUTE_SPEC: PatternSpec> = { + 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> = { + 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> = { + 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> = { + meta: {}, + query: ` + (function_call_expression + function: (name) @fn (#eq? @fn "file_get_contents") + arguments: (arguments . (argument (string) @path))) + `, +}; + +interface PhpPatternBundle { + laravelRoute: CompiledPatterns>; + httpFacade: CompiledPatterns>; + guzzleMember: CompiledPatterns>; + fileGetContents: CompiledPatterns>; +} + +const mk = (spec: PatternSpec>, suffix: string) => + compilePatterns({ + name: `php-${suffix}`, + language: PHP.php_only, + patterns: [spec], + } satisfies LanguagePatterns>); + +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; }, }; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 8ff914fcc..e81f1566e 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -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'), + ` 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'), + `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'), + ` 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');