mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
8d271813a6
3 changed files with 643 additions and 11 deletions
|
|
@ -15,20 +15,36 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
|||
* Providers:
|
||||
* - Laravel `Route::get/post/...`
|
||||
*
|
||||
* Consumers (string-literal URLs only):
|
||||
* Consumers (string-literal URLs only, unless noted):
|
||||
* - Laravel HTTP client: `Http::get/post/put/delete/patch($url)`
|
||||
* - Guzzle / generic object method: `$client->get/post/...($url)`
|
||||
* - `file_get_contents($url)`
|
||||
* - `new Request($method, $host . $resourcePath)` — the openapi-generator-php
|
||||
* / swagger-codegen client shape. `$resourcePath` is resolved via a
|
||||
* single-scope backward constant fold (see `resolveLocalStringLiteral`),
|
||||
* not a string literal at the call site itself.
|
||||
*
|
||||
* 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.
|
||||
* Scope notes: consumer patterns match string literals only, with one
|
||||
* narrow exception (above). URLs built via `sprintf`, config lookup
|
||||
* (`config('services.foo.base').'/path'`), or a variable resolved from
|
||||
* outside its own function/method body are intentionally left for a
|
||||
* follow-up — they require constant-folding beyond one local scope to
|
||||
* be meaningful.
|
||||
*
|
||||
* That narrow exception (`resolveLocalStringLiteral`) is a temporary,
|
||||
* single-scope fallback, not this language's entry into the shared
|
||||
* cross-file constant-fold used by the other languages in this plugin
|
||||
* (`constant-resolver.ts`, wired in via `java-const-resolver.ts` /
|
||||
* `python-const-resolver.ts` / `js-const-resolver.ts`). PHP has no such
|
||||
* binding yet — adding one is a real, separate project (this repo's PHP
|
||||
* import resolution for `use`-statements is its own multi-file subsystem
|
||||
* under `ingestion/import-resolvers/php.ts`, built for symbol/scope
|
||||
* resolution, not constant extraction) and is intentionally out of scope
|
||||
* here. Tracked as a follow-up, not silently punted.
|
||||
*/
|
||||
|
||||
const LARAVEL_ROUTE_SPEC: PatternSpec<Record<string, never>> = {
|
||||
|
|
@ -71,11 +87,31 @@ const FILE_GET_CONTENTS_SPEC: PatternSpec<Record<string, never>> = {
|
|||
`,
|
||||
};
|
||||
|
||||
/**
|
||||
* `new Request($method, $host . $resourcePath)` — the shape swagger-codegen /
|
||||
* openapi-generator-php emit for every operation of a generated API client
|
||||
* (Guzzle's `\GuzzleHttp\Psr7\Request`, or a bare `Request` behind a `use`
|
||||
* import). Matches both `(name)` and `(qualified_name)` class references;
|
||||
* `scan()` below filters to the last path segment being exactly `Request`
|
||||
* and resolves the concatenated path argument (see `resolveLocalStringLiteral`).
|
||||
*/
|
||||
const GUZZLE_REQUEST_CTOR_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(object_creation_expression
|
||||
[(name) (qualified_name)] @class
|
||||
(arguments
|
||||
. (argument (_) @methodArg)
|
||||
. (argument (_) @pathArg)))
|
||||
`,
|
||||
};
|
||||
|
||||
interface PhpPatternBundle {
|
||||
laravelRoute: CompiledPatterns<Record<string, never>>;
|
||||
httpFacade: CompiledPatterns<Record<string, never>>;
|
||||
guzzleMember: CompiledPatterns<Record<string, never>>;
|
||||
fileGetContents: CompiledPatterns<Record<string, never>>;
|
||||
guzzleRequestCtor: CompiledPatterns<Record<string, never>>;
|
||||
}
|
||||
|
||||
const mk = (spec: PatternSpec<Record<string, never>>, suffix: string) =>
|
||||
|
|
@ -90,6 +126,7 @@ const PHP_PATTERNS: PhpPatternBundle = {
|
|||
httpFacade: mk(HTTP_FACADE_SPEC, 'http-facade'),
|
||||
guzzleMember: mk(GUZZLE_MEMBER_SPEC, 'guzzle-member'),
|
||||
fileGetContents: mk(FILE_GET_CONTENTS_SPEC, 'file-get-contents'),
|
||||
guzzleRequestCtor: mk(GUZZLE_REQUEST_CTOR_SPEC, 'guzzle-request-ctor'),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -129,6 +166,183 @@ function isHttpUrlLiteral(path: string): boolean {
|
|||
return path.startsWith('http://') || path.startsWith('https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* Last identifier segment of a class-name reference: `(name)` returns its
|
||||
* own text, `(qualified_name)` returns the text of its last child (the
|
||||
* unqualified class name — `\GuzzleHttp\Psr7\Request` → `Request`).
|
||||
*/
|
||||
function lastNameSegment(node: import('tree-sitter').SyntaxNode): string {
|
||||
if (node.type === 'qualified_name') {
|
||||
const last = node.child(node.childCount - 1);
|
||||
return last ? last.text : node.text;
|
||||
}
|
||||
return node.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the variable at the LAST position of a `.`-concatenation
|
||||
* expression, if (and only if) that position is a plain variable —
|
||||
* generated clients build `<host> . <resourcePath>`, so the path segment
|
||||
* is the one closest to the end.
|
||||
*
|
||||
* No fallback to an earlier operand: if the rightmost position is anything
|
||||
* other than a variable, a parenthesized sub-expression, or a nested `.`
|
||||
* concatenation (a literal, a function call, ...), that position is a real
|
||||
* value we simply can't resolve — falling back to an EARLIER operand would
|
||||
* silently substitute a different value (e.g. the host) for the one that's
|
||||
* actually there. `null` here is a miss, not a signal to keep looking.
|
||||
*/
|
||||
function lastConcatVariable(
|
||||
node: import('tree-sitter').SyntaxNode,
|
||||
): import('tree-sitter').SyntaxNode | null {
|
||||
if (node.type === 'variable_name') return node;
|
||||
if (node.type === 'parenthesized_expression') {
|
||||
const inner = node.namedChild(0);
|
||||
return inner ? lastConcatVariable(inner) : null;
|
||||
}
|
||||
if (node.type === 'binary_expression') {
|
||||
const operator = node.childForFieldName('operator');
|
||||
if (!operator || operator.text !== '.') return null; // not concatenation
|
||||
const right = node.childForFieldName('right');
|
||||
return right ? lastConcatVariable(right) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `node`'s subtree assigns to `$target` ANYWHERE inside it, at any
|
||||
* depth (including inside nested functions — deliberately over-broad: a
|
||||
* false positive here only costs a miss in the caller, never a wrong
|
||||
* answer, so there's no need to be precise about scoping inside the probe
|
||||
* itself).
|
||||
*/
|
||||
function containsAssignmentTo(node: import('tree-sitter').SyntaxNode, target: string): boolean {
|
||||
if (node.type === 'assignment_expression') {
|
||||
const lhs = node.childForFieldName('left');
|
||||
if (lhs && lhs.type === 'variable_name' && lhs.text === target) return true;
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && containsAssignmentTo(child, target)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if an `anonymous_function` node's `use (...)` clause lists
|
||||
* `$target`. PHP closures capture NOTHING automatically — only variables
|
||||
* named in `use (...)` are visible inside — unlike arrow functions
|
||||
* (`fn() => ...`), which auto-capture everything by value and have no
|
||||
* `compound_statement` body of their own, so they're never seen as a
|
||||
* `scope` by the walk below in the first place.
|
||||
*/
|
||||
function anonymousFunctionCaptures(
|
||||
anonFn: import('tree-sitter').SyntaxNode,
|
||||
target: string,
|
||||
): boolean {
|
||||
for (let i = 0; i < anonFn.namedChildCount; i++) {
|
||||
const child = anonFn.namedChild(i);
|
||||
if (!child || child.type !== 'anonymous_function_use_clause') continue;
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const v = child.namedChild(j);
|
||||
if (v && v.type === 'variable_name' && v.text === target) return true;
|
||||
}
|
||||
return false; // has a use(...) clause, but $target isn't in it
|
||||
}
|
||||
return false; // no use(...) clause at all — nothing is captured
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort, single-scope constant fold: given a `variable_name` node
|
||||
* referenced inside a `new Request(...)` argument, walk BACKWARD through
|
||||
* the preceding statements of its immediately enclosing function/method
|
||||
* body (or file scope, for top-level script code) looking for the nearest
|
||||
* `$var = '<literal>';` assignment.
|
||||
*
|
||||
* "Enclosing body" is resolved level by level, not just the nearest
|
||||
* `compound_statement` — a call site nested in `if`/`foreach`/`try` inside
|
||||
* that function is still within the same function/method body, and a
|
||||
* preceding assignment above that conditional must still be found. Each
|
||||
* level searches only its own preceding siblings, then the search
|
||||
* continues from the enclosing block itself one level up, UNLESS that
|
||||
* block IS the body of a function/method/closure:
|
||||
* - a regular `function_definition` or `method_declaration` boundary
|
||||
* always stops the search — PHP gives a function or method no access
|
||||
* to anything outside its own body (no automatic capture, no implicit
|
||||
* global), so widening past one into the containing class or
|
||||
* file-level scope would resolve a variable the call site could never
|
||||
* actually see at runtime;
|
||||
* - an `anonymous_function` boundary stops UNLESS `$target` is
|
||||
* explicitly captured via `use (...)` — closures capture nothing
|
||||
* automatically either.
|
||||
* It stops at `program` regardless, for the case where the call site was
|
||||
* at file/script scope all along.
|
||||
*
|
||||
* A preceding sibling that ISN'T a plain assignment but might reassign the
|
||||
* target somewhere inside itself (an `if`/`foreach`/`try`/`switch`, ...)
|
||||
* stops the search rather than being skipped over: whether that branch ran
|
||||
* is unknown, so an older literal further back can't be trusted either.
|
||||
*
|
||||
* Deliberately conservative and bounded — no interprocedural resolution,
|
||||
* no constant/property lookups. A miss just means the endpoint stays
|
||||
* undetected, never a wrong one: this is exactly the class of case the
|
||||
* module docblock flags as in-scope only for one local scope.
|
||||
*/
|
||||
function resolveLocalStringLiteral(varNode: import('tree-sitter').SyntaxNode): string | null {
|
||||
const target = varNode.text; // includes the `$` sigil, e.g. "$resourcePath"
|
||||
let cursor: import('tree-sitter').SyntaxNode = varNode;
|
||||
|
||||
for (;;) {
|
||||
let scope: import('tree-sitter').SyntaxNode | null = cursor.parent;
|
||||
while (scope && scope.type !== 'compound_statement' && scope.type !== 'program') {
|
||||
scope = scope.parent;
|
||||
}
|
||||
if (!scope) return null;
|
||||
|
||||
let stmt: import('tree-sitter').SyntaxNode | null = cursor;
|
||||
while (stmt && stmt.parent !== scope) stmt = stmt.parent;
|
||||
if (!stmt) return null;
|
||||
|
||||
let sibling = stmt.previousNamedSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'expression_statement') {
|
||||
const inner = sibling.namedChild(0);
|
||||
if (inner && inner.type === 'assignment_expression') {
|
||||
const lhs = inner.childForFieldName('left');
|
||||
if (lhs && lhs.type === 'variable_name' && lhs.text === target) {
|
||||
// The NEAREST assignment to this variable wins, full stop — an
|
||||
// older literal further back is shadowed by this one even when
|
||||
// this one isn't itself a resolvable string (`$v = f();`).
|
||||
const rhs = inner.childForFieldName('right');
|
||||
return rhs && rhs.type === 'string' ? phpStringText(rhs) : null;
|
||||
}
|
||||
}
|
||||
} else if (containsAssignmentTo(sibling, target)) {
|
||||
return null; // reassigned somewhere inside a conditional/loop/try
|
||||
}
|
||||
sibling = sibling.previousNamedSibling;
|
||||
}
|
||||
|
||||
if (scope.type === 'program') return null;
|
||||
const enclosing = scope.parent;
|
||||
if (enclosing && enclosing.type === 'anonymous_function') {
|
||||
// Closures capture nothing automatically — only what's use()'d.
|
||||
if (!anonymousFunctionCaptures(enclosing, target)) return null;
|
||||
} else if (
|
||||
enclosing &&
|
||||
(enclosing.type === 'function_definition' || enclosing.type === 'method_declaration')
|
||||
) {
|
||||
// A regular function or method boundary — NOT a closure. PHP gives
|
||||
// these no access to anything outside their own body (no automatic
|
||||
// capture, no implicit global): widening past one into the
|
||||
// containing class body or file-level scope would resolve a
|
||||
// variable the call site could never actually see at runtime.
|
||||
return null;
|
||||
}
|
||||
cursor = scope; // one block up: search resumes from this block's own position
|
||||
}
|
||||
}
|
||||
|
||||
export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'php-http',
|
||||
language: PHP.php_only,
|
||||
|
|
@ -136,12 +350,16 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
// ingestion, so the graph is authoritative for PHP providers (#2138 Part 2).
|
||||
routeCoverage: 'complete',
|
||||
// Consumer signals scan() can detect: Laravel `Http::<verb>`, Guzzle client
|
||||
// `->get/post/.../request(...)`, and `file_get_contents` of an HTTP URL. A
|
||||
// provider-covered file with any of these must still be parsed (ingestion
|
||||
// emits no FETCHES for PHP). Conservative — the `->verb(` shape over-matches
|
||||
// ordinary method calls, which only costs a parse, never data.
|
||||
// `->get/post/.../request(...)`, `file_get_contents` of an HTTP URL, and a
|
||||
// generated-client `new ...Request(...)` constructor call. A provider-covered
|
||||
// file with any of these must still be parsed (ingestion emits no FETCHES for
|
||||
// PHP). Conservative — the `->verb(`/`new ...Request(` shapes over-match
|
||||
// ordinary method calls and unrelated constructors, which only costs a
|
||||
// parse, never data.
|
||||
hasConsumerSignals(content) {
|
||||
return /Http::|file_get_contents|->\s*(get|post|put|delete|patch|request)\s*\(/i.test(content);
|
||||
return /Http::|file_get_contents|->\s*(get|post|put|delete|patch|request)\s*\(|new\s+[\\\w]*Request\s*\(/i.test(
|
||||
content,
|
||||
);
|
||||
},
|
||||
scan(tree) {
|
||||
const out: HttpDetection[] = [];
|
||||
|
|
@ -222,6 +440,62 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
});
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(PHP_PATTERNS.guzzleRequestCtor, tree)) {
|
||||
const classNode = match.captures.class;
|
||||
const methodArg = match.captures.methodArg;
|
||||
const pathArg = match.captures.pathArg;
|
||||
if (!classNode || !methodArg || !pathArg) continue;
|
||||
// PHP class names are case-insensitive at the language level, and
|
||||
// `hasConsumerSignals` above matches case-insensitively (`/i`) for
|
||||
// the same reason — this comparison must agree with it, or a valid
|
||||
// `new request(...)` / `new \NS\REQUEST(...)` call would be waved
|
||||
// through the parse-skip gate as a signal and then silently dropped
|
||||
// here.
|
||||
if (lastNameSegment(classNode).toLowerCase() !== 'request') continue;
|
||||
|
||||
// Path: a direct string literal, or the last variable in a
|
||||
// concatenation chain (see `lastConcatVariable`) resolved to a
|
||||
// locally-assigned literal.
|
||||
let path: string | null = null;
|
||||
if (pathArg.type === 'string') {
|
||||
path = phpStringText(pathArg);
|
||||
} else {
|
||||
const lastVar = lastConcatVariable(pathArg);
|
||||
path = lastVar ? resolveLocalStringLiteral(lastVar) : null;
|
||||
}
|
||||
if (path === null || !isHttpClientPath(path)) continue;
|
||||
|
||||
// The HTTP verb is a literal, a local variable resolved the same way
|
||||
// as the path (see `resolveLocalStringLiteral` above), or — commonly
|
||||
// in generated clients — a parameter of the enclosing builder method
|
||||
// fixed by ITS caller, not by this call site. That last case needs
|
||||
// the same interprocedural reach the module docblock rules out, so it
|
||||
// falls through to a wildcard verb, matching this project's own
|
||||
// convention for a contract whose verb isn't pinned (see manifest
|
||||
// links, `http::*::`).
|
||||
let method: string | null = null;
|
||||
if (methodArg.type === 'string') {
|
||||
method = phpStringText(methodArg);
|
||||
} else if (methodArg.type === 'variable_name') {
|
||||
method = resolveLocalStringLiteral(methodArg);
|
||||
}
|
||||
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'guzzle-request-ctor',
|
||||
method: method ? method.toUpperCase() : '*',
|
||||
path,
|
||||
name: null,
|
||||
// Line of the path ARGUMENT, not the `new Request(` call — same
|
||||
// choice the other three consumer patterns in this file make, but
|
||||
// this is the one pattern where the two routinely differ (generated
|
||||
// clients wrap the call across multiple lines). Line-span
|
||||
// containment still resolves to the right symbol either way.
|
||||
line: pathArg.startPosition.row + 1,
|
||||
confidence: 0.6,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ describe('PHP hasConsumerSignals — superset of scan() consumer idioms', () =>
|
|||
['Laravel Http facade', "Http::get('/api/x');"],
|
||||
['Guzzle member call', "$client->post('/api/x', []);"],
|
||||
['file_get_contents', "file_get_contents('https://x/api');"],
|
||||
['bare Request constructor', 'new Request($method, $host . $resourcePath);'],
|
||||
['namespaced Request constructor', "new Foo\\Bar\\Request('GET', $url);"],
|
||||
])('detects %s', (_label, src) => {
|
||||
expect(has(PHP_HTTP_PLUGIN, src)).toBe(true);
|
||||
});
|
||||
|
|
@ -53,6 +55,10 @@ describe('PHP hasConsumerSignals — superset of scan() consumer idioms', () =>
|
|||
it('returns false for a pure Laravel route file (provider only)', () => {
|
||||
expect(has(PHP_HTTP_PLUGIN, "Route::get('/api/a/list', 'AController@list');")).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unrelated constructor call (not *Request)', () => {
|
||||
expect(has(PHP_HTTP_PLUGIN, 'new Response($body, 200);')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Python hasConsumerSignals — superset of scan() consumer idioms', () => {
|
||||
|
|
|
|||
352
gitnexus/test/unit/group/php-guzzle-request-ctor.test.ts
Normal file
352
gitnexus/test/unit/group/php-guzzle-request-ctor.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import PHP from 'tree-sitter-php';
|
||||
import { PHP_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/php.js';
|
||||
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(PHP.php_only);
|
||||
|
||||
const scan = (src: string) => PHP_HTTP_PLUGIN.scan(parser.parse(src));
|
||||
const consumers = (src: string) => scan(src).filter((d) => d.role === 'consumer');
|
||||
|
||||
describe('PHP guzzle-request-ctor pattern', () => {
|
||||
it('resolves a locally-assigned $resourcePath concatenated with a member-access host, method is a real parameter', () => {
|
||||
// $method is a FUNCTION PARAMETER here (the shape openapi-generator-php
|
||||
// actually emits — the verb is fixed by the caller of this builder
|
||||
// method, not assigned inside its body), not a local variable that
|
||||
// happens to share the resolver's single-scope shape. A local
|
||||
// `$method = 'POST';` immediately before the call would in fact resolve
|
||||
// via the same fold as `$resourcePath` — that's a different case,
|
||||
// covered separately below.
|
||||
const src = `<?php
|
||||
class PaymentsApi {
|
||||
public function pay($method, $order) {
|
||||
$resourcePath = '/payments/pay';
|
||||
$request = new Request(
|
||||
$method,
|
||||
$this->operationHost . $resourcePath
|
||||
);
|
||||
return $this->client->send($request);
|
||||
}
|
||||
}
|
||||
`;
|
||||
const found = consumers(src);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]).toMatchObject({
|
||||
framework: 'guzzle-request-ctor',
|
||||
method: '*', // $method is a parameter, not a literal at this call site
|
||||
path: '/payments/pay',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a fully-qualified GuzzleHttp/Psr7/Request with a literal verb', () => {
|
||||
// Built via join(), not a literal backslash in this source file: a
|
||||
// template-literal backslash-escape is easy to mis-transcribe (dropped
|
||||
// silently by the JS/TS escape rules for an unrecognized `\<char>`), so
|
||||
// this sidesteps that entirely and is robust regardless of how the file
|
||||
// itself gets written to disk.
|
||||
const bs = String.fromCharCode(92);
|
||||
const qualified = ['', 'GuzzleHttp', 'Psr7', 'Request'].join(bs);
|
||||
const src = [
|
||||
'<?php',
|
||||
'function callIt($host) {',
|
||||
" $resourcePath = '/payments/getPaymentStatus';",
|
||||
` $request = new ${qualified}('GET', $host . $resourcePath);`,
|
||||
' return $request;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
const found = consumers(src);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]).toMatchObject({
|
||||
framework: 'guzzle-request-ctor',
|
||||
method: 'GET',
|
||||
path: '/payments/getPaymentStatus',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a fully literal call with no variable to resolve', () => {
|
||||
const src = `<?php
|
||||
$request = new Request('GET', '/health');
|
||||
`;
|
||||
const found = consumers(src);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]).toMatchObject({ method: 'GET', path: '/health' });
|
||||
});
|
||||
|
||||
it('does not resolve a variable assigned in a DIFFERENT function scope', () => {
|
||||
const src = `<?php
|
||||
function setup() {
|
||||
$resourcePath = '/payments/pay';
|
||||
}
|
||||
function pay($method) {
|
||||
$request = new Request($method, $this->host . $resourcePath);
|
||||
return $request;
|
||||
}
|
||||
`;
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores an unrelated constructor whose class name does not end in "Request"', () => {
|
||||
// $resourcePath IS resolvable here (unlike an earlier version of this
|
||||
// test) — the class-name filter must be the reason this produces no
|
||||
// detection, not an incidental miss elsewhere in the pipeline. Without
|
||||
// a resolvable path, this test would pass even with the class-name
|
||||
// filter deleted.
|
||||
const src = `<?php
|
||||
function make($method) {
|
||||
$resourcePath = '/payments/pay';
|
||||
$response = new Response($method, $this->host . $resourcePath);
|
||||
return $response;
|
||||
}
|
||||
`;
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a resolved literal that is not an HTTP-looking path', () => {
|
||||
const src = `<?php
|
||||
function pay($method) {
|
||||
$resourcePath = 'not-a-path';
|
||||
$request = new Request($method, $this->host . $resourcePath);
|
||||
return $request;
|
||||
}
|
||||
`;
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('prefers the LAST variable in a 3-part concatenation (path, not an earlier segment)', () => {
|
||||
const src = `<?php
|
||||
function pay($method) {
|
||||
$base = '/not/the/path';
|
||||
$resourcePath = '/payments/pay';
|
||||
$request = new Request($method, $base . $resourcePath);
|
||||
return $request;
|
||||
}
|
||||
`;
|
||||
const found = consumers(src);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].path).toBe('/payments/pay');
|
||||
});
|
||||
|
||||
it('looks INSIDE a parenthesized right operand instead of falling back to the left one', () => {
|
||||
// Regression: an earlier version of lastConcatVariable treated an
|
||||
// unhandled `parenthesized_expression` as "no variable here" and fell
|
||||
// through to the LEFT operand — silently returning $host instead of
|
||||
// failing to find anything inside the parens.
|
||||
const src = `<?php
|
||||
function pay($method) {
|
||||
$host = 'https://api.example.com';
|
||||
$resourcePath = '/payments/pay';
|
||||
$suffix = '?x=1';
|
||||
$request = new Request($method, $host . ($resourcePath . $suffix));
|
||||
return $request;
|
||||
}
|
||||
`;
|
||||
// $suffix (the real last variable) resolves to a non-path literal, so
|
||||
// this must find NOTHING — never mistake $host for the path.
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resolves an assignment made in an ENCLOSING block within the same function (if/try nesting)', () => {
|
||||
// Regression: resolveLocalStringLiteral stopped at the nearest
|
||||
// compound_statement (the `if` block), not the enclosing function body,
|
||||
// so an assignment made just above the `if` — in the same function —
|
||||
// was invisible to a `new Request(...)` call nested inside it.
|
||||
const src = `<?php
|
||||
function pay($method, $order) {
|
||||
$resourcePath = '/payments/pay';
|
||||
if ($order->isValid()) {
|
||||
$request = new Request($method, $this->host . $resourcePath);
|
||||
return $request;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
const found = consumers(src);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].path).toBe('/payments/pay');
|
||||
});
|
||||
|
||||
it('still does not cross into a sibling function even when searching level by level', () => {
|
||||
// The level-by-level widening must stop at `program` / the enclosing
|
||||
// function boundary — it must not walk into a DIFFERENT function's body
|
||||
// just because that function is a preceding sibling statement.
|
||||
const src = `<?php
|
||||
function setup() {
|
||||
if (true) {
|
||||
$resourcePath = '/payments/pay';
|
||||
}
|
||||
}
|
||||
function pay($method) {
|
||||
if (true) {
|
||||
$request = new Request($method, $this->host . $resourcePath);
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
`;
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not resolve through an intervening non-literal reassignment (last write wins)', () => {
|
||||
// Regression: the backward scan used to skip PAST an assignment whose
|
||||
// RHS wasn't a string literal, landing on an older literal that the
|
||||
// variable no longer holds at the call site — a wrong answer, not a
|
||||
// miss. The nearest assignment must decide the outcome, full stop.
|
||||
const src = `<?php
|
||||
function pay($method) {
|
||||
$resourcePath = '/old-and-wrong';
|
||||
$resourcePath = buildPath();
|
||||
$request = new Request($method, $this->host . $resourcePath);
|
||||
return $request;
|
||||
}
|
||||
`;
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not treat a non-concatenation binary expression as a path candidate', () => {
|
||||
// Regression: lastConcatVariable recursed into ANY binary_expression
|
||||
// without checking the operator, so `$host ?? $resourcePath` (or `&&`,
|
||||
// `+`, ...) was walked exactly like `.` concatenation.
|
||||
const src = `<?php
|
||||
function pay($method) {
|
||||
$resourcePath = '/payments/pay';
|
||||
$request = new Request($method, $this->host ?? $resourcePath);
|
||||
return $request;
|
||||
}
|
||||
`;
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('matches a lowercase "request" constructor — PHP class names are case-insensitive', () => {
|
||||
const src = `<?php
|
||||
$request = new request('GET', '/health');
|
||||
`;
|
||||
const found = consumers(src);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]).toMatchObject({ method: 'GET', path: '/health' });
|
||||
});
|
||||
|
||||
it('resolves $method via the same local fold as $resourcePath when it is a local variable, not a parameter', () => {
|
||||
const src = `<?php
|
||||
function pay($order) {
|
||||
$resourcePath = '/payments/pay';
|
||||
$method = 'POST';
|
||||
$request = new Request($method, $this->host . $resourcePath);
|
||||
return $request;
|
||||
}
|
||||
`;
|
||||
const found = consumers(src);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]).toMatchObject({ method: 'POST', path: '/payments/pay' });
|
||||
});
|
||||
|
||||
it('does not use a stale literal shadowed by a reassignment inside a preceding if block', () => {
|
||||
// Regression: the backward scan only inspected direct expression_statement
|
||||
// siblings, so `$resourcePath = '/new';` nested inside an `if` right
|
||||
// before the call was invisible, and the OLDER `/old` (outside the `if`)
|
||||
// was returned instead — an unconditional wrong answer whenever that
|
||||
// branch runs, not a miss.
|
||||
const src = `<?php
|
||||
function pay($method, $cond) {
|
||||
$resourcePath = '/old-and-wrong';
|
||||
if ($cond) {
|
||||
$resourcePath = '/new';
|
||||
}
|
||||
$request = new Request($method, $this->host . $resourcePath);
|
||||
return $request;
|
||||
}
|
||||
`;
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not resolve a variable across an anonymous-function boundary it was not use()-captured into', () => {
|
||||
// Regression: level-by-level widening climbed straight from the
|
||||
// closure's body to the enclosing method's body without checking PHP's
|
||||
// actual capture rule (closures capture NOTHING unless listed in
|
||||
// `use (...)`), resolving a variable the closure can't actually see —
|
||||
// real PHP would throw "Undefined variable" here, not build this path.
|
||||
const src = `<?php
|
||||
function pay($method) {
|
||||
$resourcePath = '/payments/pay';
|
||||
$build = function () use ($method) {
|
||||
// $resourcePath is NOT captured — undefined inside this closure.
|
||||
return new Request($method, $this->host . $resourcePath);
|
||||
};
|
||||
return $build();
|
||||
}
|
||||
`;
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('DOES resolve a variable across an anonymous-function boundary it WAS use()-captured into', () => {
|
||||
const src = `<?php
|
||||
function pay($method) {
|
||||
$resourcePath = '/payments/pay';
|
||||
$build = function () use ($method, $resourcePath) {
|
||||
return new Request($method, $this->host . $resourcePath);
|
||||
};
|
||||
return $build();
|
||||
}
|
||||
`;
|
||||
const found = consumers(src);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].path).toBe('/payments/pay');
|
||||
});
|
||||
|
||||
it('does not fall back to the host when the concatenation ends in a string literal, not a variable', () => {
|
||||
// Regression: lastConcatVariable fell through to the LEFT operand when
|
||||
// the right one wasn't a variable, so `$host . '/users'` resolved to
|
||||
// $host instead of recognizing the trailing literal isn't a variable at
|
||||
// all — if $host happened to be an HTTP URL locally, that URL would be
|
||||
// emitted as the path instead of a miss.
|
||||
const src = `<?php
|
||||
function pay($method) {
|
||||
$host = 'https://api.example.com';
|
||||
$request = new Request($method, $host . '/users');
|
||||
return $request;
|
||||
}
|
||||
`;
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not resolve a variable from file/script scope into a class method (no implicit global in PHP)', () => {
|
||||
// Regression: after exhausting a method's own body, widening went
|
||||
// straight to `program` (file scope) and found the top-level literal —
|
||||
// but PHP methods have NO access to file-level variables without an
|
||||
// explicit `global $v;`, which this resolver deliberately never adds
|
||||
// support for. This produced a wrong contract, not a miss.
|
||||
const src = `<?php
|
||||
$resourcePath = '/global-and-wrong';
|
||||
|
||||
class PaymentsApi {
|
||||
public function pay($method) {
|
||||
$request = new Request($method, $this->host . $resourcePath);
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
`;
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not resolve a variable from file scope into a plain top-level function either', () => {
|
||||
const src = `<?php
|
||||
$resourcePath = '/global-and-wrong';
|
||||
|
||||
function pay($method) {
|
||||
$request = new Request($method, $GLOBALS['host'] . $resourcePath);
|
||||
return $request;
|
||||
}
|
||||
`;
|
||||
expect(consumers(src)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('DOES resolve when the call site is itself at file/script scope (no function boundary to cross)', () => {
|
||||
const src = `<?php
|
||||
$resourcePath = '/payments/pay';
|
||||
$request = new Request('GET', $host . $resourcePath);
|
||||
`;
|
||||
const found = consumers(src);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].path).toBe('/payments/pay');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue