@ihelpee/prumo-plugin-php-laravel 0.0.0-stage → 0.9.0

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.
@@ -0,0 +1,1090 @@
1
+ <?php
2
+ /**
3
+ * Prumo — AST Extractor PHP/Laravel (stdlib-only).
4
+ *
5
+ * DECISÃO D2/D3 da spec (`docs/especificacao-prumo-php-laravel.md`): a LEI mora
6
+ * no core Node. Este script é apenas um SERIALIZADOR: tokeniza com
7
+ * `token_get_all` (stdlib do PHP) e emite a AST canônica v1 em JSON no stdout.
8
+ * Nenhuma decisão de violação acontece aqui.
9
+ *
10
+ * Invariantes deste arquivo:
11
+ * - R1.1: usa apenas stdlib (token_get_all, json). Zero require de terceiros.
12
+ * - R1.2: nunca `include`/`require`/`eval` do código analisado; nunca lê
13
+ * vendor/, node_modules/, storage/.
14
+ * - R1.3: arquivo com erro de tokenização não derruba o run: emite
15
+ * { path, error: "syntax" } e segue. O processo sempre sai 0.
16
+ *
17
+ * Uso: php ast_extractor.php --root <raiz> --files <rel1> <rel2> ...
18
+ */
19
+
20
+ declare(strict_types=1);
21
+
22
+ // R1.3: stdout é o CANAL DO JSON. Qualquer warning/notice vai para stderr,
23
+ // nunca polui o stdout (o core faz JSON.parse do stdout).
24
+ ini_set('display_errors', 'stderr');
25
+ error_reporting(E_ALL);
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Operadores de membro
29
+ //
30
+ // `->` é T_OBJECT_OPERATOR, `?->` é T_NULLSAFE_OBJECT_OPERATOR, `::` é
31
+ // T_DOUBLE_COLON. Nenhum deles é um caractere literal no token stream do PHP.
32
+ // Toda heurística de "receptor->método(" passa por aqui.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function isArrow(array $tok): bool {
36
+ return is_int($tok['t'])
37
+ && ($tok['t'] === T_OBJECT_OPERATOR || $tok['t'] === T_NULLSAFE_OBJECT_OPERATOR);
38
+ }
39
+
40
+ function isDoubleColon(array $tok): bool {
41
+ return is_int($tok['t']) && $tok['t'] === T_DOUBLE_COLON;
42
+ }
43
+
44
+ /** `->`, `?->` ou `::` — qualquer acesso a membro. */
45
+ function isMemberOp(array $tok): bool {
46
+ return isArrow($tok) || isDoubleColon($tok);
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Tokenização
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * @return array<int,array{t:int|string,s:string,l:int}>|null null em erro de parse.
55
+ *
56
+ * R1.3 (sondado em PHP 8.3): `token_get_all` SIMPLES não lança ParseError em
57
+ * string não terminada, comentário não terminado, octal inválido ou chamada
58
+ * não fechada — o arquivo inválido entraria mudo no gate. `TOKEN_PARSE` roda
59
+ * o parser real por cima do token stream e lança ParseError exatamente
60
+ * nesses casos: é a detecção de sintaxe inválida exigida pela spec.
61
+ */
62
+ function tokenize(string $src): ?array {
63
+ try {
64
+ $raw = @token_get_all($src, TOKEN_PARSE);
65
+ } catch (\Throwable $e) {
66
+ return null;
67
+ }
68
+ if (!is_array($raw)) {
69
+ return null;
70
+ }
71
+ $out = [];
72
+ foreach ($raw as $tok) {
73
+ if (is_array($tok)) {
74
+ $out[] = ['t' => $tok[0], 's' => $tok[1], 'l' => $tok[2]];
75
+ } else {
76
+ $last = $out ? $out[count($out) - 1]['l'] : 1;
77
+ $out[] = ['t' => $tok, 's' => $tok, 'l' => $last];
78
+ }
79
+ }
80
+ return $out;
81
+ }
82
+
83
+ function isTrivia(array $tok): bool {
84
+ return is_int($tok['t'])
85
+ && ($tok['t'] === T_WHITESPACE || $tok['t'] === T_COMMENT || $tok['t'] === T_DOC_COMMENT);
86
+ }
87
+
88
+ /** Índices dos tokens não-triviais. */
89
+ function significant(array $toks): array {
90
+ $idx = [];
91
+ foreach ($toks as $k => $tok) {
92
+ if (isTrivia($tok)) continue;
93
+ $idx[] = $k;
94
+ }
95
+ return $idx;
96
+ }
97
+
98
+ function rawText(array $toks, int $from, int $to): string {
99
+ $parts = [];
100
+ for ($k = $from; $k <= $to && $k < count($toks); $k++) {
101
+ $parts[] = $toks[$k]['s'];
102
+ }
103
+ return implode('', $parts);
104
+ }
105
+
106
+ /** Índice do token que fecha o delimitador aberto em `open`, ou -1. */
107
+ function matchDelim(array $toks, int $open): int {
108
+ $openCh = is_string($toks[$open]['t']) ? $toks[$open]['s'] : '';
109
+ $closeMap = ['(' => ')', '[' => ']', '{' => '}'];
110
+ if (!isset($closeMap[$openCh])) return -1;
111
+ $closeCh = $closeMap[$openCh];
112
+ $depth = 0;
113
+ for ($k = $open; $k < count($toks); $k++) {
114
+ $t = $toks[$k];
115
+ if (is_string($t['t'])) {
116
+ if ($t['s'] === $openCh) $depth++;
117
+ elseif ($t['s'] === $closeCh) { $depth--; if ($depth === 0) return $k; }
118
+ }
119
+ }
120
+ return -1;
121
+ }
122
+
123
+ /** Primeiro/último índice não-trivial num intervalo, ou null. */
124
+ function sigRange(array $toks, int $from, int $to): ?array {
125
+ $f = null; $l = null;
126
+ for ($k = $from; $k <= $to && $k < count($toks); $k++) {
127
+ if (isTrivia($toks[$k])) continue;
128
+ if ($f === null) $f = $k;
129
+ $l = $k;
130
+ }
131
+ return $f === null ? null : [$f, $l];
132
+ }
133
+
134
+ /**
135
+ * Argumentos de uma chamada cujo '(' está em `open`: lista de
136
+ * {from,to} separados por vírgula em profundidade 1.
137
+ */
138
+ function callArgs(array $toks, int $open): array {
139
+ $close = matchDelim($toks, $open);
140
+ if ($close < 0) return [];
141
+ $args = [];
142
+ $depth = 0;
143
+ $start = $open + 1;
144
+ for ($k = $open; $k <= $close; $k++) {
145
+ $t = $toks[$k];
146
+ if (is_string($t['t'])) {
147
+ if ($t['s'] === '(' || $t['s'] === '[' || $t['s'] === '{') { $depth++; continue; }
148
+ if ($t['s'] === ')' || $t['s'] === ']' || $t['s'] === '}') { $depth--; }
149
+ if ($t['s'] === ',' && $depth === 1) {
150
+ $args[] = ['from' => $start, 'to' => $k - 1];
151
+ $start = $k + 1;
152
+ }
153
+ }
154
+ }
155
+ if ($start <= $close - 1) $args[] = ['from' => $start, 'to' => $close - 1];
156
+ return array_values(array_filter($args, fn($a) => sigRange($toks, $a['from'], $a['to']) !== null));
157
+ }
158
+
159
+ /** Strings literais de um intervalo. */
160
+ function stringLiterals(array $toks, int $from, int $to): array {
161
+ $out = [];
162
+ for ($k = $from; $k <= $to && $k < count($toks); $k++) {
163
+ $t = $toks[$k];
164
+ if (is_int($t['t']) && $t['t'] === T_CONSTANT_ENCAPSED_STRING) {
165
+ $out[] = trim($t['s'], "'\"");
166
+ }
167
+ }
168
+ return $out;
169
+ }
170
+
171
+ function firstString(array $toks, int $from, int $to): string {
172
+ $s = stringLiterals($toks, $from, $to);
173
+ return $s[0] ?? '';
174
+ }
175
+
176
+ function nameOf(array $toks, int $k): ?string {
177
+ if ($k < 0 || $k >= count($toks)) return null;
178
+ $t = $toks[$k];
179
+ if (is_int($t['t']) && ($t['t'] === T_STRING || $t['t'] === T_NAME_QUALIFIED || $t['t'] === T_NAME_FULLY_QUALIFIED)) {
180
+ return $t['s'];
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function shortName(string $fqcn): string {
186
+ $parts = explode('\\', $fqcn);
187
+ return $parts[count($parts) - 1];
188
+ }
189
+
190
+ /** Índice do próximo char `ch` a partir da posição de sig `fromSig`. */
191
+ function findNextSig(array $toks, array $sig, int $fromSig, string $ch): int {
192
+ for ($q = $fromSig; $q < count($sig); $q++) {
193
+ $k = $sig[$q];
194
+ if (is_string($toks[$k]['t']) && $toks[$k]['s'] === $ch) return $k;
195
+ }
196
+ return -1;
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Classificação de arquivo (heurística de caminho — spec §2.3)
201
+ // ---------------------------------------------------------------------------
202
+
203
+ function classify(string $rel): string {
204
+ $p = strtolower($rel);
205
+ if (preg_match('#(^|/)routes/#', $p)) return 'routes';
206
+ if (preg_match('#controllers?/#', $p) || preg_match('#controller\.php$#', $p)) return 'controller';
207
+ if (preg_match('#requests?/#', $p) || preg_match('#request\.php$#', $p)) return 'formrequest';
208
+ if (preg_match('#(^|/)tests?/#', $p) || preg_match('#test\.php$#', $p) || preg_match('#_test\.php$#', $p)) return 'test';
209
+ if (preg_match('#models?/#', $p) || preg_match('#model\.php$#', $p)) return 'model';
210
+ return 'other';
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Imports (arestas p/ oracle-isolation)
215
+ // ---------------------------------------------------------------------------
216
+
217
+ function extractImports(array $toks): array {
218
+ $imports = [];
219
+ $sig = significant($toks);
220
+ $count = count($sig);
221
+ $firstClass = null;
222
+ foreach ($sig as $p => $k) {
223
+ if ($toks[$k]['t'] === T_CLASS) { $firstClass = $p; break; }
224
+ }
225
+ for ($p = 0; $p < $count; $p++) {
226
+ $k = $sig[$p];
227
+ if ($toks[$k]['t'] !== T_USE) continue;
228
+ $next = $sig[$p + 1] ?? null;
229
+ if ($next !== null && is_string($toks[$next]['t']) && $toks[$next]['s'] === '(') continue; // closure use
230
+ $isTopLevel = ($firstClass === null) || ($p < $firstClass);
231
+ $q = $p + 1;
232
+ $cur = '';
233
+ while ($q < $count) {
234
+ $kk = $sig[$q];
235
+ $t = $toks[$kk];
236
+ if (is_string($t['t']) && $t['s'] === ';') break;
237
+ if (is_string($t['t']) && $t['s'] === ',') {
238
+ if ($cur !== '') $imports[] = ['fqcn' => ltrim($cur, '\\'), 'line' => $toks[$k]['l'], 'use' => true];
239
+ $cur = ''; $q++; continue;
240
+ }
241
+ if (is_int($t['t']) && $t['t'] === T_AS) break;
242
+ $nm = nameOf($toks, $kk);
243
+ if ($nm !== null) $cur .= $nm;
244
+ $q++;
245
+ }
246
+ if ($cur !== '') {
247
+ $qualified = strpos($cur, '\\') !== false;
248
+ if ($isTopLevel || $qualified) {
249
+ $imports[] = ['fqcn' => ltrim($cur, '\\'), 'line' => $toks[$k]['l'], 'use' => true];
250
+ }
251
+ }
252
+ }
253
+ return $imports;
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Rotas (com resolução obrigatória de groupMiddleware — R1.7)
258
+ // ---------------------------------------------------------------------------
259
+
260
+ const ROUTE_HTTP = ['get' => 'GET', 'post' => 'POST', 'put' => 'PUT', 'patch' => 'PATCH', 'delete' => 'DELETE', 'options' => 'OPTIONS'];
261
+
262
+ function extractRoutes(array $toks): array {
263
+ $routes = [];
264
+ $sig = significant($toks);
265
+ $count = count($sig);
266
+
267
+ $groupStack = []; // [['mw'=>[], 'depth'=>int]]
268
+ $braceDepth = 0;
269
+ $pendingGroupMw = null; // middleware aguardando o '{' do closure de grupo
270
+
271
+ for ($p = 0; $p < $count; $p++) {
272
+ $k = $sig[$p];
273
+ $t = $toks[$k];
274
+
275
+ if (is_string($t['t']) && $t['s'] === '{') {
276
+ $braceDepth++;
277
+ if ($pendingGroupMw !== null) {
278
+ $groupStack[] = ['mw' => $pendingGroupMw, 'depth' => $braceDepth];
279
+ $pendingGroupMw = null;
280
+ }
281
+ continue;
282
+ }
283
+ if (is_string($t['t']) && $t['s'] === '}') {
284
+ if ($groupStack && $groupStack[count($groupStack) - 1]['depth'] === $braceDepth) {
285
+ array_pop($groupStack);
286
+ }
287
+ $braceDepth--;
288
+ continue;
289
+ }
290
+
291
+ if (!isDoubleColon($t)) continue;
292
+ $prevName = nameOf($toks, $sig[$p - 1] ?? -1);
293
+ if ($prevName !== 'Route') continue;
294
+ $mName = nameOf($toks, $sig[$p + 1] ?? -1);
295
+ if ($mName === null) continue;
296
+ $paren = $sig[$p + 2] ?? null;
297
+ if ($paren === null || !is_string($toks[$paren]['t']) || $toks[$paren]['s'] !== '(') continue;
298
+
299
+ if ($mName === 'middleware') {
300
+ $captured = [];
301
+ foreach (callArgs($toks, $paren) as $a) {
302
+ $captured = array_merge($captured, stringLiterals($toks, $a['from'], $a['to']));
303
+ }
304
+ $pendingGroupMw = array_merge($pendingGroupMw ?? [], $captured);
305
+ continue;
306
+ }
307
+ if ($mName === 'group') {
308
+ $mw = callArgs($toks, $paren);
309
+ $captured = $mw ? stringLiterals($toks, $mw[0]['from'], $mw[0]['to']) : [];
310
+ $pendingGroupMw = array_merge($pendingGroupMw ?? [], $captured);
311
+ continue;
312
+ }
313
+ if ($mName === 'prefix' || $mName === 'name' || $mName === 'domain' || $mName === 'scopeBindings') {
314
+ continue;
315
+ }
316
+
317
+ if (isset(ROUTE_HTTP[$mName])) {
318
+ $args = callArgs($toks, $paren);
319
+ $path = $args ? firstString($toks, $args[0]['from'], $args[0]['to']) : '';
320
+ [$handler, $controller, $handlerMethod] = $args && count($args) >= 2
321
+ ? parseHandler($toks, $args[1]['from'], $args[1]['to'])
322
+ : ['Closure', null, null];
323
+ $routeMw = collectChainMiddleware($toks, $sig, $p);
324
+ $groupMw = [];
325
+ foreach ($groupStack as $g) $groupMw = array_merge($groupMw, $g['mw']);
326
+ $routes[] = [
327
+ 'method' => ROUTE_HTTP[$mName],
328
+ 'path' => '/' . ltrim($path, '/'),
329
+ 'handler' => $handler,
330
+ 'controller' => $controller,
331
+ 'handlerMethod' => $handlerMethod,
332
+ 'routeMiddleware' => array_values(array_unique($routeMw)),
333
+ 'groupMiddleware' => array_values(array_unique($groupMw)),
334
+ 'line' => $toks[$k]['l'],
335
+ ];
336
+ continue;
337
+ }
338
+
339
+ if ($mName === 'apiResource' || $mName === 'resource' || $mName === 'apiResources' || $mName === 'resources') {
340
+ $args = callArgs($toks, $paren);
341
+ $path = $args ? firstString($toks, $args[0]['from'], $args[0]['to']) : '';
342
+ $controller = null;
343
+ if ($args && count($args) >= 2) { [, $controller, ] = parseHandler($toks, $args[1]['from'], $args[1]['to']); }
344
+ $routeMw = collectChainMiddleware($toks, $sig, $p);
345
+ $groupMw = [];
346
+ foreach ($groupStack as $g) $groupMw = array_merge($groupMw, $g['mw']);
347
+ $base = '/' . trim($path, '/');
348
+ $expansion = [
349
+ ['GET', $base, 'index'],
350
+ ['POST', $base, 'store'],
351
+ ['GET', $base . '/{id}', 'show'],
352
+ ['PUT', $base . '/{id}', 'update'],
353
+ ['PATCH', $base . '/{id}', 'update'],
354
+ ['DELETE', $base . '/{id}', 'destroy'],
355
+ ];
356
+ foreach ($expansion as [$m, $pth, $hm]) {
357
+ $routes[] = [
358
+ 'method' => $m,
359
+ 'path' => $pth,
360
+ 'handler' => $controller ? ($controller . '@' . $hm) : 'Closure',
361
+ 'controller' => $controller,
362
+ 'handlerMethod' => $hm,
363
+ 'routeMiddleware' => array_values(array_unique($routeMw)),
364
+ 'groupMiddleware' => array_values(array_unique($groupMw)),
365
+ 'line' => $toks[$k]['l'],
366
+ ];
367
+ }
368
+ continue;
369
+ }
370
+ }
371
+ return $routes;
372
+ }
373
+
374
+ /** Handler: [Class::class,'m'] | 'Class@m' | 'Class' | closure. */
375
+ function parseHandler(array $toks, int $from, int $to): array {
376
+ $range = sigRange($toks, $from, $to);
377
+ if ($range === null) return ['Closure', null, null];
378
+ [$f, $l] = $range;
379
+ // closure
380
+ if (is_int($toks[$f]['t']) && ($toks[$f]['t'] === T_FUNCTION || $toks[$f]['t'] === T_FN)) {
381
+ return ['Closure', null, null];
382
+ }
383
+ // array [Class::class, 'm'] — o primeiro token é '['
384
+ if (is_string($toks[$f]['t']) && $toks[$f]['s'] === '[') {
385
+ $names = []; $method = null;
386
+ for ($k = $f; $k <= $l; $k++) {
387
+ $t = $toks[$k];
388
+ if (is_int($t['t']) && $t['t'] === T_STRING) $names[] = $t['s'];
389
+ if (is_int($t['t']) && $t['t'] === T_CONSTANT_ENCAPSED_STRING) $method = trim($t['s'], "'\"");
390
+ }
391
+ $class = null;
392
+ for ($j = 0; $j < count($names); $j++) {
393
+ if ($names[$j] === 'class') { $class = $names[$j - 1] ?? null; break; }
394
+ }
395
+ if ($class === null && $names) $class = $names[0];
396
+ $class = $class ? shortName($class) : null;
397
+ $handler = $class ? ($class . '@' . ($method ?? '')) : 'Closure';
398
+ return [$handler, $class, $method];
399
+ }
400
+ // Class::class (resource/invokable) — T_STRING seguido de ::class.
401
+ // Com TOKEN_PARSE o `class` mágico chega como T_STRING, não T_CLASS.
402
+ if (is_int($toks[$f]['t']) && ($toks[$f]['t'] === T_STRING || $toks[$f]['t'] === T_NAME_QUALIFIED || $toks[$f]['t'] === T_NAME_FULLY_QUALIFIED)) {
403
+ $n1 = $f + 1; while ($n1 <= $l && isTrivia($toks[$n1])) $n1++;
404
+ $n2 = $n1 + 1; while ($n2 <= $l && isTrivia($toks[$n2])) $n2++;
405
+ if ($n1 <= $l && isDoubleColon($toks[$n1]) && $n2 <= $l && is_int($toks[$n2]['t'])
406
+ && ($toks[$n2]['t'] === T_CLASS || ($toks[$n2]['t'] === T_STRING && $toks[$n2]['s'] === 'class'))) {
407
+ $class = shortName($toks[$f]['s']);
408
+ return [$class, $class, null];
409
+ }
410
+ }
411
+ // string 'Class@m' | 'Class'
412
+ $strs = stringLiterals($toks, $from, $to);
413
+ if ($strs) {
414
+ $s = $strs[0];
415
+ if (strpos($s, '@') !== false) {
416
+ [$c, $m] = explode('@', $s, 2);
417
+ return [$s, shortName($c), $m];
418
+ }
419
+ return [$s, shortName($s), null];
420
+ }
421
+ return ['Closure', null, null];
422
+ }
423
+
424
+ /** `->middleware(...)` encadeados na statement de rota corrente. */
425
+ function collectChainMiddleware(array $toks, array $sig, int $pSig): array {
426
+ $out = [];
427
+ $count = count($sig);
428
+ $parenDepth = 0; $braceDepth = 0;
429
+ for ($q = $pSig; $q < $count; $q++) {
430
+ $k = $sig[$q];
431
+ $t = $toks[$k];
432
+ if (is_string($t['t'])) {
433
+ if ($t['s'] === '(') $parenDepth++;
434
+ elseif ($t['s'] === ')') $parenDepth--;
435
+ elseif ($t['s'] === '{') $braceDepth++;
436
+ elseif ($t['s'] === '}') $braceDepth--;
437
+ elseif ($t['s'] === ';' && $parenDepth <= 0 && $braceDepth <= 0) break;
438
+ }
439
+ // nova statement Route:: encerra a chain
440
+ if (isDoubleColon($t) && nameOf($toks, $sig[$q - 1] ?? -1) === 'Route' && $q !== $pSig) break;
441
+ // ->middleware(
442
+ if (isArrow($t) && nameOf($toks, $sig[$q + 1] ?? -1) === 'middleware') {
443
+ $paren = $sig[$q + 2] ?? null;
444
+ if ($paren !== null && is_string($toks[$paren]['t']) && $toks[$paren]['s'] === '(') {
445
+ foreach (callArgs($toks, $paren) as $a) {
446
+ $out = array_merge($out, stringLiterals($toks, $a['from'], $a['to']));
447
+ }
448
+ }
449
+ }
450
+ }
451
+ return $out;
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // Controllers (middleware + métodos) — R1.8 (3 estilos)
456
+ // ---------------------------------------------------------------------------
457
+
458
+ /** Nome da classe que `class X extends Y` estende (ou null). */
459
+ function extendsOf(array $toks, array $sig, int $classSig): ?string {
460
+ for ($q = $classSig; $q < count($sig); $q++) {
461
+ $k = $sig[$q];
462
+ if ($toks[$k]['t'] === T_EXTENDS) {
463
+ return nameOf($toks, $sig[$q + 1] ?? -1);
464
+ }
465
+ if ($toks[$k]['t'] === T_CLASS) continue;
466
+ if (is_string($toks[$k]['t']) && $toks[$k]['s'] === '{') break;
467
+ }
468
+ return null;
469
+ }
470
+
471
+ function isControllerClass(array $toks, array $sig, int $classSig, string $class): bool {
472
+ if (substr($class, -10) === 'Controller') return true;
473
+ $ext = extendsOf($toks, $sig, $classSig);
474
+ return $ext !== null && substr($ext, -10) === 'Controller';
475
+ }
476
+
477
+ function extractControllers(array $toks): array {
478
+ $controllers = [];
479
+ $sig = significant($toks);
480
+ $count = count($sig);
481
+ for ($p = 0; $p < $count; $p++) {
482
+ $k = $sig[$p];
483
+ if ($toks[$k]['t'] !== T_CLASS) continue;
484
+ $class = nameOf($toks, $sig[$p + 1] ?? -1);
485
+ if ($class === null) continue;
486
+ if (!isControllerClass($toks, $sig, $p, $class)) continue;
487
+ $bodyOpen = findNextSig($toks, $sig, $p, '{');
488
+ if ($bodyOpen < 0) continue;
489
+ $bodyClose = matchDelim($toks, $bodyOpen);
490
+ if ($bodyClose < 0) continue;
491
+
492
+ $middleware = [];
493
+ $middleware = array_merge($middleware, propertyArray($toks, $sig, $bodyOpen, $bodyClose, 'middleware'));
494
+ $middleware = array_merge($middleware, constructMiddleware($toks, $sig, $bodyOpen, $bodyClose));
495
+ $middleware = array_merge($middleware, staticMiddleware($toks, $sig, $bodyOpen, $bodyClose));
496
+
497
+ $methods = [];
498
+ for ($q = $p; $q < $count; $q++) {
499
+ $mk = $sig[$q];
500
+ if ($toks[$mk]['t'] !== T_FUNCTION) continue;
501
+ if ($mk < $bodyOpen || $mk > $bodyClose) continue;
502
+ $mname = nameOf($toks, $sig[$q + 1] ?? -1);
503
+ if ($mname === null || $mname === '__construct') continue;
504
+ $mOpen = findNextSig($toks, $sig, $q, '{');
505
+ if ($mOpen < 0 || $mOpen > $bodyClose) continue;
506
+ $mClose = matchDelim($toks, $mOpen);
507
+ if ($mClose < 0) continue;
508
+
509
+ $requestParam = requestParamType($toks, $sig, $q);
510
+ $varName = requestVarName($toks, $sig, $q);
511
+ $rawPayload = rawPayloadCalls($toks, $sig, $mOpen, $mClose, $varName);
512
+ $validatesInline = bodyHasCall($toks, $sig, $mOpen, $mClose, 'validate')
513
+ || bodyHasCall($toks, $sig, $mOpen, $mClose, 'validated')
514
+ || bodyHasCall($toks, $sig, $mOpen, $mClose, 'safe');
515
+ $unguard = bodyHasCall($toks, $sig, $mOpen, $mClose, 'unguard');
516
+
517
+ $methods[] = [
518
+ 'name' => $mname,
519
+ 'line' => $toks[$mk]['l'],
520
+ 'requestParam' => $requestParam,
521
+ 'rawPayloadCalls' => $rawPayload,
522
+ 'validatesInline' => $validatesInline,
523
+ 'unguard' => $unguard,
524
+ ];
525
+ }
526
+
527
+ $controllers[] = [
528
+ 'class' => $class,
529
+ 'middleware' => array_values(array_unique($middleware)),
530
+ 'methods' => $methods,
531
+ ];
532
+ }
533
+ return $controllers;
534
+ }
535
+
536
+ function propertyArray(array $toks, array $sig, int $bodyOpen, int $bodyClose, string $prop): array {
537
+ $out = [];
538
+ for ($q = 0; $q < count($sig); $q++) {
539
+ $k = $sig[$q];
540
+ if ($k <= $bodyOpen || $k >= $bodyClose) continue;
541
+ if (is_int($toks[$k]['t']) && $toks[$k]['t'] === T_VARIABLE && $toks[$k]['s'] === '$' . $prop) {
542
+ $eq = $sig[$q + 1] ?? null;
543
+ if ($eq === null || !is_string($toks[$eq]['t']) || $toks[$eq]['s'] !== '=') continue;
544
+ $arr = $sig[$q + 2] ?? null;
545
+ if ($arr === null || !is_string($toks[$arr]['t']) || $toks[$arr]['s'] !== '[') continue;
546
+ $close = matchDelim($toks, $arr);
547
+ if ($close < 0) continue;
548
+ $out = array_merge($out, stringLiterals($toks, $arr, $close));
549
+ }
550
+ }
551
+ return $out;
552
+ }
553
+
554
+ function constructMiddleware(array $toks, array $sig, int $bodyOpen, int $bodyClose): array {
555
+ $out = [];
556
+ for ($q = 0; $q < count($sig); $q++) {
557
+ $k = $sig[$q];
558
+ if ($k <= $bodyOpen || $k >= $bodyClose) continue;
559
+ if ($toks[$k]['t'] === T_FUNCTION && nameOf($toks, $sig[$q + 1] ?? -1) === '__construct') {
560
+ $cOpen = findNextSig($toks, $sig, $q, '{');
561
+ if ($cOpen < 0) continue;
562
+ $cClose = matchDelim($toks, $cOpen);
563
+ if ($cClose < 0) continue;
564
+ for ($r = 0; $r < count($sig); $r++) {
565
+ $rk = $sig[$r];
566
+ if ($rk <= $cOpen || $rk >= $cClose) continue;
567
+ if (isArrow($toks[$rk]) && nameOf($toks, $sig[$r + 1] ?? -1) === 'middleware') {
568
+ $paren = $sig[$r + 2] ?? null;
569
+ if ($paren !== null && is_string($toks[$paren]['t']) && $toks[$paren]['s'] === '(') {
570
+ foreach (callArgs($toks, $paren) as $a) {
571
+ $out = array_merge($out, stringLiterals($toks, $a['from'], $a['to']));
572
+ }
573
+ }
574
+ }
575
+ }
576
+ }
577
+ }
578
+ return $out;
579
+ }
580
+
581
+ function staticMiddleware(array $toks, array $sig, int $bodyOpen, int $bodyClose): array {
582
+ $out = [];
583
+ for ($q = 0; $q < count($sig); $q++) {
584
+ $k = $sig[$q];
585
+ if ($k <= $bodyOpen || $k >= $bodyClose) continue;
586
+ if ($toks[$k]['t'] === T_FUNCTION && nameOf($toks, $sig[$q + 1] ?? -1) === 'middleware') {
587
+ $mOpen = findNextSig($toks, $sig, $q, '{');
588
+ if ($mOpen < 0) continue;
589
+ $mClose = matchDelim($toks, $mOpen);
590
+ if ($mClose < 0) continue;
591
+ $out = array_merge($out, stringLiterals($toks, $mOpen, $mClose));
592
+ }
593
+ }
594
+ return $out;
595
+ }
596
+
597
+ function requestParamType(array $toks, array $sig, int $fnSig): ?string {
598
+ $paren = findNextSig($toks, $sig, $fnSig, '(');
599
+ if ($paren < 0) return null;
600
+ $close = matchDelim($toks, $paren);
601
+ if ($close < 0) return null;
602
+ for ($k = $paren + 1; $k < $close; $k++) {
603
+ $t = $toks[$k];
604
+ if (is_int($t['t']) && ($t['t'] === T_STRING || $t['t'] === T_NAME_QUALIFIED)) {
605
+ $nm = $t['s'];
606
+ if (substr($nm, -7) === 'Request') return shortName($nm);
607
+ }
608
+ }
609
+ return null;
610
+ }
611
+
612
+ function requestVarName(array $toks, array $sig, int $fnSig): ?string {
613
+ $paren = findNextSig($toks, $sig, $fnSig, '(');
614
+ if ($paren < 0) return null;
615
+ $close = matchDelim($toks, $paren);
616
+ if ($close < 0) return null;
617
+ $lastType = null;
618
+ for ($k = $paren + 1; $k < $close; $k++) {
619
+ $t = $toks[$k];
620
+ if (is_int($t['t']) && ($t['t'] === T_STRING || $t['t'] === T_NAME_QUALIFIED)) $lastType = $t['s'];
621
+ if (is_int($t['t']) && $t['t'] === T_VARIABLE && $lastType !== null && substr($lastType, -7) === 'Request') {
622
+ return $t['s'];
623
+ }
624
+ }
625
+ return null;
626
+ }
627
+
628
+ /** Chamadas `$<var>->all|input|json(` no corpo. */
629
+ function rawPayloadCalls(array $toks, array $sig, int $open, int $close, ?string $varName): array {
630
+ $out = [];
631
+ $targets = ['all', 'input', 'json'];
632
+ for ($q = 0; $q < count($sig); $q++) {
633
+ $k = $sig[$q];
634
+ if ($k <= $open || $k >= $close) continue;
635
+ if (isArrow($toks[$k])) {
636
+ $method = nameOf($toks, $sig[$q + 1] ?? -1);
637
+ if ($method !== null && in_array($method, $targets, true)) {
638
+ $prev = $sig[$q - 1] ?? null;
639
+ if ($prev !== null && is_int($toks[$prev]['t']) && $toks[$prev]['t'] === T_VARIABLE) {
640
+ if ($varName === null || $toks[$prev]['s'] === $varName) $out[] = $method;
641
+ }
642
+ }
643
+ }
644
+ }
645
+ return array_values(array_unique($out));
646
+ }
647
+
648
+ /** Corpo contém `-><name>(` ou `<Class>::<name>(`? */
649
+ function bodyHasCall(array $toks, array $sig, int $open, int $close, string $name): bool {
650
+ for ($q = 0; $q < count($sig); $q++) {
651
+ $k = $sig[$q];
652
+ if ($k <= $open || $k >= $close) continue;
653
+ if (isMemberOp($toks[$k])) {
654
+ $n = nameOf($toks, $sig[$q + 1] ?? -1);
655
+ if ($n === $name) {
656
+ $paren = $sig[$q + 2] ?? null;
657
+ if ($paren !== null && is_string($toks[$paren]['t']) && $toks[$paren]['s'] === '(') return true;
658
+ }
659
+ }
660
+ }
661
+ return false;
662
+ }
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // FormRequests
666
+ // ---------------------------------------------------------------------------
667
+
668
+ function isFormRequestClass(array $toks, array $sig, int $classSig, string $class): bool {
669
+ $ext = extendsOf($toks, $sig, $classSig);
670
+ if ($ext !== null && (substr($ext, -7) === 'Request' || $ext === 'FormRequest')) return true;
671
+ if (substr($class, -7) === 'Request') return true;
672
+ return false;
673
+ }
674
+
675
+ function extractFormRequests(array $toks): array {
676
+ $out = [];
677
+ $sig = significant($toks);
678
+ $count = count($sig);
679
+ for ($p = 0; $p < $count; $p++) {
680
+ $k = $sig[$p];
681
+ if ($toks[$k]['t'] !== T_CLASS) continue;
682
+ $class = nameOf($toks, $sig[$p + 1] ?? -1);
683
+ if ($class === null) continue;
684
+ if (!isFormRequestClass($toks, $sig, $p, $class)) continue;
685
+ $bodyOpen = findNextSig($toks, $sig, $p, '{');
686
+ if ($bodyOpen < 0) continue;
687
+ $bodyClose = matchDelim($toks, $bodyOpen);
688
+ if ($bodyClose < 0) continue;
689
+
690
+ $authorize = false; $authorizeRaw = ''; $hasRules = false;
691
+
692
+ for ($q = $p; $q < $count; $q++) {
693
+ $mk = $sig[$q];
694
+ if ($toks[$mk]['t'] !== T_FUNCTION) continue;
695
+ if ($mk < $bodyOpen || $mk > $bodyClose) continue;
696
+ $mname = nameOf($toks, $sig[$q + 1] ?? -1);
697
+ $mOpen = findNextSig($toks, $sig, $q, '{');
698
+ if ($mOpen < 0 || $mOpen > $bodyClose) continue;
699
+ $mClose = matchDelim($toks, $mOpen);
700
+ if ($mClose < 0) continue;
701
+ if ($mname === 'authorize') {
702
+ $authorizeRaw = trim(rawText($toks, $mOpen + 1, $mClose - 1));
703
+ $authorize = (bool) preg_match('/return\s+true\b/', $authorizeRaw);
704
+ } elseif ($mname === 'rules' || $mname === 'rulesArray') {
705
+ $hasRules = $hasRules || rulesBodyNonEmpty($toks, $mOpen, $mClose);
706
+ }
707
+ }
708
+
709
+ $out[] = [
710
+ 'class' => $class,
711
+ 'authorize' => $authorize,
712
+ 'authorizeRaw' => $authorizeRaw,
713
+ 'hasRules' => $hasRules,
714
+ 'line' => $toks[$k]['l'],
715
+ ];
716
+ }
717
+ return $out;
718
+ }
719
+
720
+ function rulesBodyNonEmpty(array $toks, int $open, int $close): bool {
721
+ for ($q = $open; $q <= $close; $q++) {
722
+ if (is_string($toks[$q]['t']) && $toks[$q]['s'] === '[') {
723
+ $c = matchDelim($toks, $q);
724
+ if ($c < 0) continue;
725
+ if (stringLiterals($toks, $q, $c)) return true;
726
+ }
727
+ }
728
+ return false;
729
+ }
730
+
731
+ // ---------------------------------------------------------------------------
732
+ // Raw queries (tripwire no-raw-queries — R2.9/R2.10/R2.11)
733
+ // ---------------------------------------------------------------------------
734
+
735
+ const RAW_STATIC = ['raw', 'select', 'statement', 'insert', 'update', 'delete'];
736
+ const RAW_CHAIN = ['whereRaw', 'orWhereRaw', 'havingRaw', 'selectRaw', 'orderByRaw', 'whereSelect'];
737
+
738
+ function extractRawQueries(array $toks): array {
739
+ $out = [];
740
+ $sig = significant($toks);
741
+ $count = count($sig);
742
+ for ($q = 0; $q < $count; $q++) {
743
+ $k = $sig[$q];
744
+ $t = $toks[$k];
745
+
746
+ if (isDoubleColon($t)) {
747
+ $recv = nameOf($toks, $sig[$q - 1] ?? -1);
748
+ $mname = nameOf($toks, $sig[$q + 1] ?? -1);
749
+ if ($recv === 'DB' && $mname !== null && in_array($mname, RAW_STATIC, true)) {
750
+ $paren = $sig[$q + 2] ?? null;
751
+ if ($paren !== null && is_string($toks[$paren]['t']) && $toks[$paren]['s'] === '(') {
752
+ $out[] = rawQueryEntry($toks, $paren, 'DB::' . $mname, $toks[$k]['l']);
753
+ }
754
+ }
755
+ continue;
756
+ }
757
+ if (isArrow($t)) {
758
+ $mname = nameOf($toks, $sig[$q + 1] ?? -1);
759
+ if ($mname !== null && in_array($mname, RAW_CHAIN, true)) {
760
+ $paren = $sig[$q + 2] ?? null;
761
+ if ($paren !== null && is_string($toks[$paren]['t']) && $toks[$paren]['s'] === '(') {
762
+ $out[] = rawQueryEntry($toks, $paren, $mname, $toks[$k]['l']);
763
+ }
764
+ }
765
+ }
766
+ }
767
+ return $out;
768
+ }
769
+
770
+ function rawQueryEntry(array $toks, int $paren, string $call, int $line): array {
771
+ $args = callArgs($toks, $paren);
772
+ $interpolated = false; $hasBindings = false;
773
+ if ($args) {
774
+ $sqlRange = sigRange($toks, $args[0]['from'], $args[0]['to']);
775
+ if ($sqlRange) {
776
+ [$f, $l] = $sqlRange;
777
+ for ($k = $f; $k <= $l; $k++) {
778
+ if (is_int($toks[$k]['t']) && $toks[$k]['t'] === T_VARIABLE) { $interpolated = true; break; }
779
+ }
780
+ $sqlText = rawText($toks, $f, $l);
781
+ if (strpos($sqlText, '?') !== false || preg_match('/:[a-zA-Z_][a-zA-Z0-9_]*/', $sqlText)) {
782
+ $hasBindings = true;
783
+ }
784
+ }
785
+ if (count($args) >= 2) {
786
+ $b = sigRange($toks, $args[1]['from'], $args[1]['to']);
787
+ if ($b && is_string($toks[$b[0]]['t']) && $toks[$b[0]]['s'] === '[') $hasBindings = true;
788
+ }
789
+ }
790
+ return ['call' => $call, 'interpolated' => $interpolated, 'hasBindings' => $hasBindings, 'line' => $line];
791
+ }
792
+
793
+ // ---------------------------------------------------------------------------
794
+ // Service-locator (anti-burla R3.3 — spec §6.4 / card F1)
795
+ //
796
+ // `app(FQCN::class)`, `resolve(FQCN::class)`, `make(FQCN::class)` e
797
+ // `Container::make|get(FQCN::class)` são IMPORT DINÂMICO: o teste que não
798
+ // escreveu `use App\...` ainda assim alcança a unidade que deveria fiscalizar
799
+ // de fora. Sem esta aresta, o service-locator é a burla que vira a lei de
800
+ // cabeça para baixo (spec §8: "Burla via app(Service::class) no oráculo").
801
+ // Só resolve para FQCN explícito (`X::class`); string dinâmica não resolvida
802
+ // não entra (heurística conservadora, calibrada por fixture).
803
+ // ---------------------------------------------------------------------------
804
+
805
+ const LOCATOR_FUNCS = ['app', 'resolve', 'make'];
806
+
807
+ /**
808
+ * FQCN de um intervalo `FQCN::class` (com barra inicial), ou null.
809
+ * Atenção: `from`/`to` vêm de `callArgs` e são índices do array BRUTO de
810
+ * tokens — o avanço aqui pula trivia em índice bruto (não em `sig`).
811
+ */
812
+ function classConstFqcn(array $toks, int $from, int $to): ?string {
813
+ $range = sigRange($toks, $from, $to);
814
+ if ($range === null) return null;
815
+ [$f, $l] = $range;
816
+ $name = nameOf($toks, $f);
817
+ if ($name === null) return null;
818
+ $n1 = $f + 1; while ($n1 <= $l && isTrivia($toks[$n1])) $n1++;
819
+ if ($n1 > $l || !isDoubleColon($toks[$n1])) return null;
820
+ $n2 = $n1 + 1; while ($n2 <= $l && isTrivia($toks[$n2])) $n2++;
821
+ if ($n2 > $l || !is_int($toks[$n2]['t'])) return null;
822
+ // Com TOKEN_PARSE o `class` de `X::class` chega como T_STRING (não
823
+ // T_CLASS); o texto é o discriminador — `Foo::bar` não vira aresta.
824
+ if ($toks[$n2]['s'] !== 'class') return null;
825
+ if ($toks[$n2]['t'] !== T_CLASS && $toks[$n2]['t'] !== T_STRING) return null;
826
+ return ltrim($name, chr(92));
827
+ }
828
+
829
+ /**
830
+ * Chamadas de service-locator num intervalo. Retorna lista de
831
+ * { fqcn, line } com o FQCN resolvido do argumento `FQCN::class`.
832
+ */
833
+ function serviceLocatorCalls(array $toks, array $sig, int $open, int $close): array {
834
+ $out = [];
835
+ $count = count($sig);
836
+ for ($q = 0; $q < $count; $q++) {
837
+ $k = $sig[$q];
838
+ if ($k < $open || $k > $close) continue;
839
+ $t = $toks[$k];
840
+
841
+ $fqcn = null;
842
+ if (is_int($t['t']) && $t['t'] === T_STRING && in_array($t['s'], LOCATOR_FUNCS, true)) {
843
+ // app()/resolve()/make() globais — não pode ser método ($this->app(), Route::make()...)
844
+ $prev = $sig[$q - 1] ?? null;
845
+ if ($prev !== null && isMemberOp($toks[$prev])) continue;
846
+ $paren = $sig[$q + 1] ?? null;
847
+ if ($paren === null || !is_string($toks[$paren]['t']) || $toks[$paren]['s'] !== '(') continue;
848
+ $args = callArgs($toks, $paren);
849
+ if ($args) $fqcn = classConstFqcn($toks, $args[0]['from'], $args[0]['to']);
850
+ } elseif (isDoubleColon($t)) {
851
+ // Container::make|get(FQCN::class) — receiver pode ser FQCN
852
+ // (\Illuminate\Support\Facades\Container::make).
853
+ $recv = nameOf($toks, $sig[$q - 1] ?? -1);
854
+ $mname = nameOf($toks, $sig[$q + 1] ?? -1);
855
+ if ($recv === null || shortName($recv) !== 'Container' || ($mname !== 'make' && $mname !== 'get')) continue;
856
+ $paren = $sig[$q + 2] ?? null;
857
+ if ($paren === null || !is_string($toks[$paren]['t']) || $toks[$paren]['s'] !== '(') continue;
858
+ $args = callArgs($toks, $paren);
859
+ if ($args) $fqcn = classConstFqcn($toks, $args[0]['from'], $args[0]['to']);
860
+ } elseif (isArrow($t)) {
861
+ // ->make(FQCN::class) / ->resolve(FQCN::class) em qualquer receptor
862
+ // ($this->app->make(), app()->make(), $container->make()): é a
863
+ // mesma resolução dinâmica com FQCN explícito — anti-burla.
864
+ $mname = nameOf($toks, $sig[$q + 1] ?? -1);
865
+ if ($mname !== 'make' && $mname !== 'resolve') continue;
866
+ $paren = $sig[$q + 2] ?? null;
867
+ if ($paren === null || !is_string($toks[$paren]['t']) || $toks[$paren]['s'] !== '(') continue;
868
+ $args = callArgs($toks, $paren);
869
+ if ($args) $fqcn = classConstFqcn($toks, $args[0]['from'], $args[0]['to']);
870
+ }
871
+ if ($fqcn !== null && $fqcn !== '') {
872
+ $out[] = ['fqcn' => $fqcn, 'line' => $toks[$k]['l']];
873
+ }
874
+ }
875
+ return $out;
876
+ }
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // Tests (PHPUnit + Pest) — regras de oráculo
880
+ // ---------------------------------------------------------------------------
881
+
882
+ /**
883
+ * Extrai classes de teste + service-locators do arquivo inteiro.
884
+ * @return array{0: array, 1: array} [tests, serviceLocator]
885
+ */
886
+ function extractTests(array $toks): array {
887
+ $out = [];
888
+ $sig = significant($toks);
889
+ $count = count($sig);
890
+ // Anti-burla (spec §6.4): service-locator no arquivo inteiro — vale também
891
+ // fora de método de teste (setUp, helpers de topo, Pest de topo).
892
+ $locator = $count > 0 ? serviceLocatorCalls($toks, $sig, $sig[0], $sig[$count - 1]) : [];
893
+ $appImports = [];
894
+ foreach (extractImports($toks) as $im) {
895
+ if (preg_match('#^App\\\\#', $im['fqcn'])) $appImports[] = $im['fqcn'];
896
+ }
897
+
898
+ for ($p = 0; $p < $count; $p++) {
899
+ $k = $sig[$p];
900
+ if ($toks[$k]['t'] !== T_CLASS) continue;
901
+ $class = nameOf($toks, $sig[$p + 1] ?? -1);
902
+ if ($class === null) continue;
903
+ $bodyOpen = findNextSig($toks, $sig, $p, '{');
904
+ if ($bodyOpen < 0) continue;
905
+ $bodyClose = matchDelim($toks, $bodyOpen);
906
+ if ($bodyClose < 0) continue;
907
+
908
+ $traits = classTraits($toks, $sig, $bodyOpen, $bodyClose);
909
+ $methods = [];
910
+ for ($q = $p; $q < $count; $q++) {
911
+ $mk = $sig[$q];
912
+ if ($toks[$mk]['t'] !== T_FUNCTION) continue;
913
+ if ($mk < $bodyOpen || $mk > $bodyClose) continue;
914
+ $mname = nameOf($toks, $sig[$q + 1] ?? -1);
915
+ if ($mname === null) continue;
916
+ if (strpos($mname, 'test') !== 0 && !hasTestAnnotation($toks, $mk)) continue;
917
+ $mOpen = findNextSig($toks, $sig, $q, '{');
918
+ if ($mOpen < 0 || $mOpen > $bodyClose) continue;
919
+ $mClose = matchDelim($toks, $mOpen);
920
+ if ($mClose < 0) continue;
921
+
922
+ $methods[] = [
923
+ 'name' => $mname,
924
+ 'line' => $toks[$mk]['l'],
925
+ 'withoutMiddleware' => bodyHasCall($toks, $sig, $mOpen, $mClose, 'withoutMiddleware'),
926
+ 'actingAs' => bodyHasCall($toks, $sig, $mOpen, $mClose, 'actingAs'),
927
+ 'containerFakes' => containerFakes($toks, $sig, $mOpen, $mClose),
928
+ 'mockOnly' => isMockOnly($toks, $sig, $mOpen, $mClose),
929
+ 'importsApp' => $appImports,
930
+ 'serviceLocator' => serviceLocatorCalls($toks, $sig, $mOpen, $mClose),
931
+ ];
932
+ }
933
+ if ($methods) $out[] = ['class' => $class, 'traits' => $traits, 'methods' => $methods];
934
+ }
935
+
936
+ foreach (pestTests($toks, $sig, $appImports) as $pt) $out[] = $pt;
937
+ return [$out, $locator];
938
+ }
939
+
940
+ function hasTestAnnotation(array $toks, int $fnK): bool {
941
+ for ($k = $fnK - 1; $k >= 0; $k--) {
942
+ $t = $toks[$k];
943
+ if (is_int($t['t']) && $t['t'] === T_DOC_COMMENT) return (bool) preg_match('/@test\b/', $t['s']);
944
+ if (is_int($t['t']) && $t['t'] !== T_WHITESPACE && $t['t'] !== T_COMMENT) break;
945
+ }
946
+ return false;
947
+ }
948
+
949
+ function classTraits(array $toks, array $sig, int $bodyOpen, int $bodyClose): array {
950
+ $traits = [];
951
+ for ($q = 0; $q < count($sig); $q++) {
952
+ $k = $sig[$q];
953
+ if ($k <= $bodyOpen || $k >= $bodyClose) continue;
954
+ if ($toks[$k]['t'] === T_USE) {
955
+ $r = $q + 1;
956
+ while ($r < count($sig)) {
957
+ $rk = $sig[$r];
958
+ if (is_string($toks[$rk]['t']) && $toks[$rk]['s'] === ';') break;
959
+ $nm = nameOf($toks, $rk);
960
+ if ($nm !== null) $traits[] = shortName($nm);
961
+ $r++;
962
+ }
963
+ }
964
+ }
965
+ return array_values(array_unique($traits));
966
+ }
967
+
968
+ function containerFakes(array $toks, array $sig, int $open, int $close): array {
969
+ $found = [];
970
+ $names = ['mock', 'partialMock', 'swap', 'fake'];
971
+ for ($q = 0; $q < count($sig); $q++) {
972
+ $k = $sig[$q];
973
+ if ($k <= $open || $k >= $close) continue;
974
+ if (isMemberOp($toks[$k])) {
975
+ $m = nameOf($toks, $sig[$q + 1] ?? -1);
976
+ if ($m !== null && in_array($m, $names, true)) {
977
+ $paren = $sig[$q + 2] ?? null;
978
+ if ($paren !== null && is_string($toks[$paren]['t']) && $toks[$paren]['s'] === '(') $found[] = $m;
979
+ }
980
+ }
981
+ if (isDoubleColon($toks[$k]) && nameOf($toks, $sig[$q - 1] ?? -1) === 'Container' && nameOf($toks, $sig[$q + 1] ?? -1) === 'set') {
982
+ $found[] = 'set';
983
+ }
984
+ }
985
+ return array_values(array_unique($found));
986
+ }
987
+
988
+ function isMockOnly(array $toks, array $sig, int $open, int $close): bool {
989
+ $hasMock = false; $hasDomain = false;
990
+ $mockNames = ['expects', 'shouldReceive', 'mock', 'partialMock'];
991
+ $domainAssert = ['assertSame', 'assertEquals', 'assertDatabaseHas', 'assertDatabaseMissing', 'assertJsonPath', 'assertJson', 'assertExactJson', 'assertTrue', 'assertFalse', 'assertNull', 'assertNotNull', 'assertCount', 'assertGreaterThan', 'assertLessThan'];
992
+ for ($q = 0; $q < count($sig); $q++) {
993
+ $k = $sig[$q];
994
+ if ($k <= $open || $k >= $close) continue;
995
+ if (isMemberOp($toks[$k])) {
996
+ $m = nameOf($toks, $sig[$q + 1] ?? -1);
997
+ if ($m === null) continue;
998
+ if (in_array($m, $mockNames, true)) $hasMock = true;
999
+ if (preg_match('/assert\w*Called$/', $m)) $hasMock = true;
1000
+ if (in_array($m, $domainAssert, true)) $hasDomain = true;
1001
+ if (preg_match('/assert(Status|JsonStructure|Header|Cookie)/', $m)) $hasDomain = true;
1002
+ }
1003
+ }
1004
+ return $hasMock && !$hasDomain;
1005
+ }
1006
+
1007
+ function pestTests(array $toks, array $sig, array $appImports): array {
1008
+ $methods = [];
1009
+ $count = count($sig);
1010
+ for ($q = 0; $q < $count; $q++) {
1011
+ $k = $sig[$q];
1012
+ if (!is_int($toks[$k]['t']) || $toks[$k]['t'] !== T_STRING) continue;
1013
+ $fn = $toks[$k]['s'];
1014
+ if (!in_array($fn, ['test', 'it', 'describe'], true)) continue;
1015
+ $prev = $sig[$q - 1] ?? null;
1016
+ if ($prev !== null && isMemberOp($toks[$prev])) continue; // não é chamada de topo
1017
+ $paren = $sig[$q + 1] ?? null;
1018
+ if ($paren === null || !is_string($toks[$paren]['t']) || $toks[$paren]['s'] !== '(') continue;
1019
+ $args = callArgs($toks, $paren);
1020
+ if (!$args) continue;
1021
+ $name = firstString($toks, $args[0]['from'], $args[0]['to']);
1022
+ if ($name === '') continue;
1023
+ $body = $args[count($args) - 1];
1024
+ $methods[] = [
1025
+ 'name' => $name,
1026
+ 'line' => $toks[$k]['l'],
1027
+ 'withoutMiddleware' => bodyHasCall($toks, $sig, $body['from'], $body['to'], 'withoutMiddleware'),
1028
+ 'actingAs' => bodyHasCall($toks, $sig, $body['from'], $body['to'], 'actingAs'),
1029
+ 'containerFakes' => containerFakes($toks, $sig, $body['from'], $body['to']),
1030
+ 'mockOnly' => isMockOnly($toks, $sig, $body['from'], $body['to']),
1031
+ 'importsApp' => $appImports,
1032
+ 'serviceLocator' => serviceLocatorCalls($toks, $sig, $body['from'], $body['to']),
1033
+ ];
1034
+ }
1035
+ if (!$methods) return [];
1036
+ return [['class' => '(Pest)', 'traits' => [], 'methods' => $methods]];
1037
+ }
1038
+
1039
+ // ---------------------------------------------------------------------------
1040
+ // Driver
1041
+ // ---------------------------------------------------------------------------
1042
+
1043
+ $root = null;
1044
+ $files = [];
1045
+ $argvv = $_SERVER['argv'] ?? [];
1046
+ $n = count($argvv);
1047
+ $i = 1;
1048
+ while ($i < $n) {
1049
+ $a = $argvv[$i];
1050
+ if ($a === '--root') { $i++; if ($i < $n) $root = $argvv[$i]; }
1051
+ elseif ($a === '--files') { $i++; while ($i < $n) { $files[] = $argvv[$i]; $i++; } break; }
1052
+ $i++;
1053
+ }
1054
+ if ($root === null) {
1055
+ fwrite(STDERR, "uso: ast_extractor.php --root <raiz> --files <...>\n");
1056
+ echo json_encode(['version' => 1, 'files' => []]), "\n";
1057
+ exit(0);
1058
+ }
1059
+ $root = rtrim($root, '/');
1060
+
1061
+ $blocked = ['#(^|/)vendor/#', '#(^|/)node_modules/#', '#(^|/)storage/#'];
1062
+ $resultFiles = [];
1063
+
1064
+ foreach ($files as $rel) {
1065
+ $rel = str_replace('\\', '/', $rel);
1066
+ $skip = false;
1067
+ foreach ($blocked as $b) { if (preg_match($b, $rel)) { $skip = true; break; } }
1068
+ if ($skip) continue;
1069
+ $abs = $root . '/' . ltrim($rel, '/');
1070
+ if (!is_file($abs)) { $resultFiles[] = ['path' => $rel, 'error' => 'missing']; continue; }
1071
+ $src = @file_get_contents($abs);
1072
+ if ($src === false) { $resultFiles[] = ['path' => $rel, 'error' => 'unreadable']; continue; }
1073
+ $toks = tokenize($src);
1074
+ if ($toks === null) { $resultFiles[] = ['path' => $rel, 'error' => 'syntax']; continue; }
1075
+ [$tests, $locator] = extractTests($toks);
1076
+ $resultFiles[] = [
1077
+ 'path' => $rel,
1078
+ 'kind' => classify($rel),
1079
+ 'imports' => extractImports($toks),
1080
+ 'routes' => extractRoutes($toks),
1081
+ 'controllers' => extractControllers($toks),
1082
+ 'formRequests' => extractFormRequests($toks),
1083
+ 'rawQueries' => extractRawQueries($toks),
1084
+ 'tests' => $tests,
1085
+ 'serviceLocator' => $locator,
1086
+ ];
1087
+ }
1088
+
1089
+ echo json_encode(['version' => 1, 'files' => $resultFiles], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";
1090
+ exit(0);