gitnexus 1.6.10 → 1.6.11-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,20 +6,36 @@ import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-s
6
6
  * Providers:
7
7
  * - Laravel `Route::get/post/...`
8
8
  *
9
- * Consumers (string-literal URLs only):
9
+ * Consumers (string-literal URLs only, unless noted):
10
10
  * - Laravel HTTP client: `Http::get/post/put/delete/patch($url)`
11
11
  * - Guzzle / generic object method: `$client->get/post/...($url)`
12
12
  * - `file_get_contents($url)`
13
+ * - `new Request($method, $host . $resourcePath)` — the openapi-generator-php
14
+ * / swagger-codegen client shape. `$resourcePath` is resolved via a
15
+ * single-scope backward constant fold (see `resolveLocalStringLiteral`),
16
+ * not a string literal at the call site itself.
13
17
  *
14
18
  * The pipeline already uses `PHP.php_only` for ingesting plain `.php`
15
19
  * files (see `core/tree-sitter/parser-loader.ts`), and we do the same
16
20
  * here so Laravel route files are parsed with the right grammar dialect.
17
21
  *
18
- * Scope notes: consumer patterns match string literals only. URLs built
19
- * via binary concatenation (`$base . '/path'`), `sprintf`, or config
20
- * lookup (`config('services.foo.base').'/path'`) are intentionally left
21
- * for a follow-up — they require constant-folding the surrounding
22
- * scope to be meaningful.
22
+ * Scope notes: consumer patterns match string literals only, with one
23
+ * narrow exception (above). URLs built via `sprintf`, config lookup
24
+ * (`config('services.foo.base').'/path'`), or a variable resolved from
25
+ * outside its own function/method body are intentionally left for a
26
+ * follow-up — they require constant-folding beyond one local scope to
27
+ * be meaningful.
28
+ *
29
+ * That narrow exception (`resolveLocalStringLiteral`) is a temporary,
30
+ * single-scope fallback, not this language's entry into the shared
31
+ * cross-file constant-fold used by the other languages in this plugin
32
+ * (`constant-resolver.ts`, wired in via `java-const-resolver.ts` /
33
+ * `python-const-resolver.ts` / `js-const-resolver.ts`). PHP has no such
34
+ * binding yet — adding one is a real, separate project (this repo's PHP
35
+ * import resolution for `use`-statements is its own multi-file subsystem
36
+ * under `ingestion/import-resolvers/php.ts`, built for symbol/scope
37
+ * resolution, not constant extraction) and is intentionally out of scope
38
+ * here. Tracked as a follow-up, not silently punted.
23
39
  */
24
40
  const LARAVEL_ROUTE_SPEC = {
25
41
  meta: {},
@@ -57,6 +73,24 @@ const FILE_GET_CONTENTS_SPEC = {
57
73
  arguments: (arguments . (argument (string) @path)))
58
74
  `,
59
75
  };
76
+ /**
77
+ * `new Request($method, $host . $resourcePath)` — the shape swagger-codegen /
78
+ * openapi-generator-php emit for every operation of a generated API client
79
+ * (Guzzle's `\GuzzleHttp\Psr7\Request`, or a bare `Request` behind a `use`
80
+ * import). Matches both `(name)` and `(qualified_name)` class references;
81
+ * `scan()` below filters to the last path segment being exactly `Request`
82
+ * and resolves the concatenated path argument (see `resolveLocalStringLiteral`).
83
+ */
84
+ const GUZZLE_REQUEST_CTOR_SPEC = {
85
+ meta: {},
86
+ query: `
87
+ (object_creation_expression
88
+ [(name) (qualified_name)] @class
89
+ (arguments
90
+ . (argument (_) @methodArg)
91
+ . (argument (_) @pathArg)))
92
+ `,
93
+ };
60
94
  const mk = (spec, suffix) => compilePatterns({
61
95
  name: `php-${suffix}`,
62
96
  language: PHP.php_only,
@@ -67,6 +101,7 @@ const PHP_PATTERNS = {
67
101
  httpFacade: mk(HTTP_FACADE_SPEC, 'http-facade'),
68
102
  guzzleMember: mk(GUZZLE_MEMBER_SPEC, 'guzzle-member'),
69
103
  fileGetContents: mk(FILE_GET_CONTENTS_SPEC, 'file-get-contents'),
104
+ guzzleRequestCtor: mk(GUZZLE_REQUEST_CTOR_SPEC, 'guzzle-request-ctor'),
70
105
  };
71
106
  /**
72
107
  * Extract the inner text of a PHP `string` node. The tree-sitter-php
@@ -103,6 +138,180 @@ function isHttpClientPath(path) {
103
138
  function isHttpUrlLiteral(path) {
104
139
  return path.startsWith('http://') || path.startsWith('https://');
105
140
  }
141
+ /**
142
+ * Last identifier segment of a class-name reference: `(name)` returns its
143
+ * own text, `(qualified_name)` returns the text of its last child (the
144
+ * unqualified class name — `\GuzzleHttp\Psr7\Request` → `Request`).
145
+ */
146
+ function lastNameSegment(node) {
147
+ if (node.type === 'qualified_name') {
148
+ const last = node.child(node.childCount - 1);
149
+ return last ? last.text : node.text;
150
+ }
151
+ return node.text;
152
+ }
153
+ /**
154
+ * Return the variable at the LAST position of a `.`-concatenation
155
+ * expression, if (and only if) that position is a plain variable —
156
+ * generated clients build `<host> . <resourcePath>`, so the path segment
157
+ * is the one closest to the end.
158
+ *
159
+ * No fallback to an earlier operand: if the rightmost position is anything
160
+ * other than a variable, a parenthesized sub-expression, or a nested `.`
161
+ * concatenation (a literal, a function call, ...), that position is a real
162
+ * value we simply can't resolve — falling back to an EARLIER operand would
163
+ * silently substitute a different value (e.g. the host) for the one that's
164
+ * actually there. `null` here is a miss, not a signal to keep looking.
165
+ */
166
+ function lastConcatVariable(node) {
167
+ if (node.type === 'variable_name')
168
+ return node;
169
+ if (node.type === 'parenthesized_expression') {
170
+ const inner = node.namedChild(0);
171
+ return inner ? lastConcatVariable(inner) : null;
172
+ }
173
+ if (node.type === 'binary_expression') {
174
+ const operator = node.childForFieldName('operator');
175
+ if (!operator || operator.text !== '.')
176
+ return null; // not concatenation
177
+ const right = node.childForFieldName('right');
178
+ return right ? lastConcatVariable(right) : null;
179
+ }
180
+ return null;
181
+ }
182
+ /**
183
+ * True if `node`'s subtree assigns to `$target` ANYWHERE inside it, at any
184
+ * depth (including inside nested functions — deliberately over-broad: a
185
+ * false positive here only costs a miss in the caller, never a wrong
186
+ * answer, so there's no need to be precise about scoping inside the probe
187
+ * itself).
188
+ */
189
+ function containsAssignmentTo(node, target) {
190
+ if (node.type === 'assignment_expression') {
191
+ const lhs = node.childForFieldName('left');
192
+ if (lhs && lhs.type === 'variable_name' && lhs.text === target)
193
+ return true;
194
+ }
195
+ for (let i = 0; i < node.namedChildCount; i++) {
196
+ const child = node.namedChild(i);
197
+ if (child && containsAssignmentTo(child, target))
198
+ return true;
199
+ }
200
+ return false;
201
+ }
202
+ /**
203
+ * True if an `anonymous_function` node's `use (...)` clause lists
204
+ * `$target`. PHP closures capture NOTHING automatically — only variables
205
+ * named in `use (...)` are visible inside — unlike arrow functions
206
+ * (`fn() => ...`), which auto-capture everything by value and have no
207
+ * `compound_statement` body of their own, so they're never seen as a
208
+ * `scope` by the walk below in the first place.
209
+ */
210
+ function anonymousFunctionCaptures(anonFn, target) {
211
+ for (let i = 0; i < anonFn.namedChildCount; i++) {
212
+ const child = anonFn.namedChild(i);
213
+ if (!child || child.type !== 'anonymous_function_use_clause')
214
+ continue;
215
+ for (let j = 0; j < child.namedChildCount; j++) {
216
+ const v = child.namedChild(j);
217
+ if (v && v.type === 'variable_name' && v.text === target)
218
+ return true;
219
+ }
220
+ return false; // has a use(...) clause, but $target isn't in it
221
+ }
222
+ return false; // no use(...) clause at all — nothing is captured
223
+ }
224
+ /**
225
+ * Best-effort, single-scope constant fold: given a `variable_name` node
226
+ * referenced inside a `new Request(...)` argument, walk BACKWARD through
227
+ * the preceding statements of its immediately enclosing function/method
228
+ * body (or file scope, for top-level script code) looking for the nearest
229
+ * `$var = '<literal>';` assignment.
230
+ *
231
+ * "Enclosing body" is resolved level by level, not just the nearest
232
+ * `compound_statement` — a call site nested in `if`/`foreach`/`try` inside
233
+ * that function is still within the same function/method body, and a
234
+ * preceding assignment above that conditional must still be found. Each
235
+ * level searches only its own preceding siblings, then the search
236
+ * continues from the enclosing block itself one level up, UNLESS that
237
+ * block IS the body of a function/method/closure:
238
+ * - a regular `function_definition` or `method_declaration` boundary
239
+ * always stops the search — PHP gives a function or method no access
240
+ * to anything outside its own body (no automatic capture, no implicit
241
+ * global), so widening past one into the containing class or
242
+ * file-level scope would resolve a variable the call site could never
243
+ * actually see at runtime;
244
+ * - an `anonymous_function` boundary stops UNLESS `$target` is
245
+ * explicitly captured via `use (...)` — closures capture nothing
246
+ * automatically either.
247
+ * It stops at `program` regardless, for the case where the call site was
248
+ * at file/script scope all along.
249
+ *
250
+ * A preceding sibling that ISN'T a plain assignment but might reassign the
251
+ * target somewhere inside itself (an `if`/`foreach`/`try`/`switch`, ...)
252
+ * stops the search rather than being skipped over: whether that branch ran
253
+ * is unknown, so an older literal further back can't be trusted either.
254
+ *
255
+ * Deliberately conservative and bounded — no interprocedural resolution,
256
+ * no constant/property lookups. A miss just means the endpoint stays
257
+ * undetected, never a wrong one: this is exactly the class of case the
258
+ * module docblock flags as in-scope only for one local scope.
259
+ */
260
+ function resolveLocalStringLiteral(varNode) {
261
+ const target = varNode.text; // includes the `$` sigil, e.g. "$resourcePath"
262
+ let cursor = varNode;
263
+ for (;;) {
264
+ let scope = cursor.parent;
265
+ while (scope && scope.type !== 'compound_statement' && scope.type !== 'program') {
266
+ scope = scope.parent;
267
+ }
268
+ if (!scope)
269
+ return null;
270
+ let stmt = cursor;
271
+ while (stmt && stmt.parent !== scope)
272
+ stmt = stmt.parent;
273
+ if (!stmt)
274
+ return null;
275
+ let sibling = stmt.previousNamedSibling;
276
+ while (sibling) {
277
+ if (sibling.type === 'expression_statement') {
278
+ const inner = sibling.namedChild(0);
279
+ if (inner && inner.type === 'assignment_expression') {
280
+ const lhs = inner.childForFieldName('left');
281
+ if (lhs && lhs.type === 'variable_name' && lhs.text === target) {
282
+ // The NEAREST assignment to this variable wins, full stop — an
283
+ // older literal further back is shadowed by this one even when
284
+ // this one isn't itself a resolvable string (`$v = f();`).
285
+ const rhs = inner.childForFieldName('right');
286
+ return rhs && rhs.type === 'string' ? phpStringText(rhs) : null;
287
+ }
288
+ }
289
+ }
290
+ else if (containsAssignmentTo(sibling, target)) {
291
+ return null; // reassigned somewhere inside a conditional/loop/try
292
+ }
293
+ sibling = sibling.previousNamedSibling;
294
+ }
295
+ if (scope.type === 'program')
296
+ return null;
297
+ const enclosing = scope.parent;
298
+ if (enclosing && enclosing.type === 'anonymous_function') {
299
+ // Closures capture nothing automatically — only what's use()'d.
300
+ if (!anonymousFunctionCaptures(enclosing, target))
301
+ return null;
302
+ }
303
+ else if (enclosing &&
304
+ (enclosing.type === 'function_definition' || enclosing.type === 'method_declaration')) {
305
+ // A regular function or method boundary — NOT a closure. PHP gives
306
+ // these no access to anything outside their own body (no automatic
307
+ // capture, no implicit global): widening past one into the
308
+ // containing class body or file-level scope would resolve a
309
+ // variable the call site could never actually see at runtime.
310
+ return null;
311
+ }
312
+ cursor = scope; // one block up: search resumes from this block's own position
313
+ }
314
+ }
106
315
  export const PHP_HTTP_PLUGIN = {
107
316
  name: 'php-http',
108
317
  language: PHP.php_only,
@@ -110,12 +319,14 @@ export const PHP_HTTP_PLUGIN = {
110
319
  // ingestion, so the graph is authoritative for PHP providers (#2138 Part 2).
111
320
  routeCoverage: 'complete',
112
321
  // Consumer signals scan() can detect: Laravel `Http::<verb>`, Guzzle client
113
- // `->get/post/.../request(...)`, and `file_get_contents` of an HTTP URL. A
114
- // provider-covered file with any of these must still be parsed (ingestion
115
- // emits no FETCHES for PHP). Conservative — the `->verb(` shape over-matches
116
- // ordinary method calls, which only costs a parse, never data.
322
+ // `->get/post/.../request(...)`, `file_get_contents` of an HTTP URL, and a
323
+ // generated-client `new ...Request(...)` constructor call. A provider-covered
324
+ // file with any of these must still be parsed (ingestion emits no FETCHES for
325
+ // PHP). Conservative — the `->verb(`/`new ...Request(` shapes over-match
326
+ // ordinary method calls and unrelated constructors, which only costs a
327
+ // parse, never data.
117
328
  hasConsumerSignals(content) {
118
- return /Http::|file_get_contents|->\s*(get|post|put|delete|patch|request)\s*\(/i.test(content);
329
+ return /Http::|file_get_contents|->\s*(get|post|put|delete|patch|request)\s*\(|new\s+[\\\w]*Request\s*\(/i.test(content);
119
330
  },
120
331
  scan(tree) {
121
332
  const out = [];
@@ -199,6 +410,63 @@ export const PHP_HTTP_PLUGIN = {
199
410
  confidence: 0.7,
200
411
  });
201
412
  }
413
+ for (const match of runCompiledPatterns(PHP_PATTERNS.guzzleRequestCtor, tree)) {
414
+ const classNode = match.captures.class;
415
+ const methodArg = match.captures.methodArg;
416
+ const pathArg = match.captures.pathArg;
417
+ if (!classNode || !methodArg || !pathArg)
418
+ continue;
419
+ // PHP class names are case-insensitive at the language level, and
420
+ // `hasConsumerSignals` above matches case-insensitively (`/i`) for
421
+ // the same reason — this comparison must agree with it, or a valid
422
+ // `new request(...)` / `new \NS\REQUEST(...)` call would be waved
423
+ // through the parse-skip gate as a signal and then silently dropped
424
+ // here.
425
+ if (lastNameSegment(classNode).toLowerCase() !== 'request')
426
+ continue;
427
+ // Path: a direct string literal, or the last variable in a
428
+ // concatenation chain (see `lastConcatVariable`) resolved to a
429
+ // locally-assigned literal.
430
+ let path = null;
431
+ if (pathArg.type === 'string') {
432
+ path = phpStringText(pathArg);
433
+ }
434
+ else {
435
+ const lastVar = lastConcatVariable(pathArg);
436
+ path = lastVar ? resolveLocalStringLiteral(lastVar) : null;
437
+ }
438
+ if (path === null || !isHttpClientPath(path))
439
+ continue;
440
+ // The HTTP verb is a literal, a local variable resolved the same way
441
+ // as the path (see `resolveLocalStringLiteral` above), or — commonly
442
+ // in generated clients — a parameter of the enclosing builder method
443
+ // fixed by ITS caller, not by this call site. That last case needs
444
+ // the same interprocedural reach the module docblock rules out, so it
445
+ // falls through to a wildcard verb, matching this project's own
446
+ // convention for a contract whose verb isn't pinned (see manifest
447
+ // links, `http::*::`).
448
+ let method = null;
449
+ if (methodArg.type === 'string') {
450
+ method = phpStringText(methodArg);
451
+ }
452
+ else if (methodArg.type === 'variable_name') {
453
+ method = resolveLocalStringLiteral(methodArg);
454
+ }
455
+ out.push({
456
+ role: 'consumer',
457
+ framework: 'guzzle-request-ctor',
458
+ method: method ? method.toUpperCase() : '*',
459
+ path,
460
+ name: null,
461
+ // Line of the path ARGUMENT, not the `new Request(` call — same
462
+ // choice the other three consumer patterns in this file make, but
463
+ // this is the one pattern where the two routinely differ (generated
464
+ // clients wrap the call across multiple lines). Line-span
465
+ // containment still resolves to the right symbol either way.
466
+ line: pathArg.startPosition.row + 1,
467
+ confidence: 0.6,
468
+ });
469
+ }
202
470
  return out;
203
471
  },
204
472
  };