@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.
- package/LICENSE +202 -0
- package/README.md +68 -2
- package/dist/ast.d.ts +114 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +126 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/rules.d.ts +120 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +506 -0
- package/package.json +35 -3
- package/php/ast_extractor.php +1090 -0
package/dist/rules.js
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
2
|
+
import { rule, definePlugin, } from '@ihelpee/prumo/invariants';
|
|
3
|
+
import { extractPhpModel } from './ast.js';
|
|
4
|
+
/**
|
|
5
|
+
* Invariantes PHP/Laravel (spec `docs/especificacao-prumo-php-laravel.md` §3/§4).
|
|
6
|
+
* Os ids são canônicos e congelados; nenhum card filho pode renomeá-los sem
|
|
7
|
+
* voltar à especificação.
|
|
8
|
+
*
|
|
9
|
+
* Fronteiras honestas (Art. IV): cada regra declara o que NÃO pega.
|
|
10
|
+
* O modelo vem do subprocesso PHP (`needsData('php')`); a LEI está toda aqui.
|
|
11
|
+
*/
|
|
12
|
+
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
13
|
+
/**
|
|
14
|
+
* R2.2 — piso de símbolos que contam como proteção. `throttle:api` NÃO conta
|
|
15
|
+
* (rate-limit ≠ auth). Qualquer middleware cujo nome case com
|
|
16
|
+
* /auth|permission|role|guard/i conta. `plugin.authSymbols` (spec §5.2)
|
|
17
|
+
* ACRESCENTA símbolos do consumidor sobre este piso (match exato ou glob com
|
|
18
|
+
* `*`); nunca subtrai — o piso é o mínimo estrutural da regra.
|
|
19
|
+
*/
|
|
20
|
+
const AUTH_SYMBOL_RE = /^(auth(:|$)|can(:|$)|verified$|EnsureEmailIsVerified$)/i;
|
|
21
|
+
const AUTH_HINT_RE = /auth|permission|role|guard/i;
|
|
22
|
+
function globToRe(pattern) {
|
|
23
|
+
const esc = pattern.replace(/[.*+?^${}()|[\]\\]/g, (c) => (c === '*' ? '.*' : '\\' + c));
|
|
24
|
+
return new RegExp(`^${esc}$`, 'i');
|
|
25
|
+
}
|
|
26
|
+
function isAuthSymbol(mw, extra) {
|
|
27
|
+
if (AUTH_SYMBOL_RE.test(mw) || AUTH_HINT_RE.test(mw))
|
|
28
|
+
return true;
|
|
29
|
+
return extra.some((s) => (s.includes('*') ? globToRe(s).test(mw) : s.toLowerCase() === mw.toLowerCase()));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* R2.3 — exceção declarada: rota marcada com símbolo de `publicSymbols`
|
|
33
|
+
* (convenção opt-in do consumidor, ex.: `public`) é pública intencional.
|
|
34
|
+
*/
|
|
35
|
+
function isPublicSymbol(mw, publicSymbols) {
|
|
36
|
+
return publicSymbols.some((s) => (s.includes('*') ? globToRe(s).test(mw) : s.toLowerCase() === mw.toLowerCase()));
|
|
37
|
+
}
|
|
38
|
+
/** Convenção Laravel: diretórios de oráculo quando o config não é visível. */
|
|
39
|
+
const DEFAULT_ORACLE_DIRS = ['tests/Oracle', 'tests/Feature/Oracle', 'oracle'];
|
|
40
|
+
function model(ctx) {
|
|
41
|
+
return (ctx.get('php') ?? null);
|
|
42
|
+
}
|
|
43
|
+
function isOracleFile(path, oracleDirs) {
|
|
44
|
+
const p = path.replace(/\\/g, '/');
|
|
45
|
+
return oracleDirs.some((d) => p === d || p.startsWith(d.replace(/\/+$/, '') + '/'));
|
|
46
|
+
}
|
|
47
|
+
/** `path` está sob algum prefixo de diretório (mesma semântica de isOracleFile). */
|
|
48
|
+
function isUnderAnyDir(path, dirs) {
|
|
49
|
+
const p = path.replace(/\\/g, '/');
|
|
50
|
+
return dirs.some((d) => p === d || p.startsWith(d.replace(/\/+$/, '') + '/'));
|
|
51
|
+
}
|
|
52
|
+
function controllersByName(m) {
|
|
53
|
+
const out = new Map();
|
|
54
|
+
for (const f of m.files) {
|
|
55
|
+
for (const c of f.controllers ?? []) {
|
|
56
|
+
if (!out.has(c.class))
|
|
57
|
+
out.set(c.class, c.middleware);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// F2 — Invariantes de aplicação
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
/**
|
|
66
|
+
* I1 / R2.1–R2.4 — rota de mutação (`POST/PUT/PATCH/DELETE`) sem middleware
|
|
67
|
+
* de autenticação/autorização em NENHUMA camada efetiva: middleware da rota,
|
|
68
|
+
* middleware do grupo aninhado, ou middleware do controller resolvido.
|
|
69
|
+
*
|
|
70
|
+
* R2.2/R2.3 (preset §5.2): `authSymbols` acrescenta símbolos de proteção ao
|
|
71
|
+
* piso estrutural; `publicSymbols` marca rota pública intencional (opt-in do
|
|
72
|
+
* consumidor) — sem convenção declarada, rota de mutação sem auth é sempre
|
|
73
|
+
* finding.
|
|
74
|
+
*
|
|
75
|
+
* Não pega: middleware global do stack web/api (exigiria ler
|
|
76
|
+
* bootstrap/app.php/Kernel — fronteira declarada; cubra via authSymbols no
|
|
77
|
+
* controller). Rotas GET nunca são alvo.
|
|
78
|
+
*/
|
|
79
|
+
export function makeMutationRoutesGuarded(authSymbols, publicSymbols) {
|
|
80
|
+
return rule('mutation-routes-guarded')
|
|
81
|
+
.needsData('php')
|
|
82
|
+
.severity('error')
|
|
83
|
+
.message('rota de mutação Laravel sem middleware de auth em nenhuma camada (rota, grupo aninhado ou controller): em produção ela é pública, e actingAs() no teste forja a proteção que não existe')
|
|
84
|
+
.assert((ctx) => {
|
|
85
|
+
const m = model(ctx);
|
|
86
|
+
if (!m)
|
|
87
|
+
return [];
|
|
88
|
+
const out = [];
|
|
89
|
+
const ctrls = controllersByName(m);
|
|
90
|
+
for (const f of m.files) {
|
|
91
|
+
for (const r of f.routes ?? []) {
|
|
92
|
+
if (!MUTATING.has(r.method))
|
|
93
|
+
continue;
|
|
94
|
+
const ctrlMw = r.controller ? (ctrls.get(r.controller) ?? []) : [];
|
|
95
|
+
const layers = [...r.routeMiddleware, ...r.groupMiddleware, ...ctrlMw];
|
|
96
|
+
if (layers.some((mw) => isPublicSymbol(mw, publicSymbols)))
|
|
97
|
+
continue;
|
|
98
|
+
if (layers.some((mw) => isAuthSymbol(mw, authSymbols)))
|
|
99
|
+
continue;
|
|
100
|
+
const label = r.controller
|
|
101
|
+
? `${r.controller}${r.handlerMethod ? '.' + r.handlerMethod : ''}`
|
|
102
|
+
: 'Closure';
|
|
103
|
+
out.push({
|
|
104
|
+
target: `${f.path}:${r.line} ${r.method} ${r.path} (${label})`,
|
|
105
|
+
location: `${f.path}:${r.line}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
})
|
|
111
|
+
.build();
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* I2 / R2.5–R2.6 — método de controller com `Request` cru consumindo payload
|
|
115
|
+
* via all()/input()/json() sem validação inline; ou payload cru com
|
|
116
|
+
* `unguard()` no corpo (mass assignment forçado, mesmo com FormRequest).
|
|
117
|
+
*
|
|
118
|
+
* Não pega: `$request->validate([...])` inline (satisfaz, R2.8) e
|
|
119
|
+
* FormRequest tipado com rules() real.
|
|
120
|
+
*/
|
|
121
|
+
export const formRequestValidated = rule('form-request-validated')
|
|
122
|
+
.needsData('php')
|
|
123
|
+
.severity('error')
|
|
124
|
+
.message('controller consome $request->all()/input()/json() cru sem FormRequest nem validate() — vetor clássico de mass assignment; use FormRequest com rules() ou $request->validate([...])')
|
|
125
|
+
.assert((ctx) => {
|
|
126
|
+
const m = model(ctx);
|
|
127
|
+
if (!m)
|
|
128
|
+
return [];
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const f of m.files) {
|
|
131
|
+
for (const c of f.controllers ?? []) {
|
|
132
|
+
for (const meth of c.methods) {
|
|
133
|
+
const raw = meth.rawPayloadCalls.length > 0;
|
|
134
|
+
if (!raw)
|
|
135
|
+
continue;
|
|
136
|
+
const rawRequest = meth.requestParam === 'Request';
|
|
137
|
+
if ((rawRequest && !meth.validatesInline) || meth.unguard) {
|
|
138
|
+
out.push({
|
|
139
|
+
target: `${f.path}:${meth.line} ${c.class}.${meth.name} usa $request->${meth.rawPayloadCalls[0]}() sem validação${meth.unguard ? ' + unguard()' : ''}`,
|
|
140
|
+
location: `${f.path}:${meth.line}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
})
|
|
148
|
+
.build();
|
|
149
|
+
/**
|
|
150
|
+
* R2.7 — FormRequest oco: classe gerada por `php artisan make:request` sem
|
|
151
|
+
* rules() preenchido (o agente faz isso constantemente) — autorização
|
|
152
|
+
* decorativa sobre rota que parece protegida. `warn`: a classe pode ser
|
|
153
|
+
* legítima em migração progressiva.
|
|
154
|
+
*/
|
|
155
|
+
export const formRequestHollow = rule('form-request-hollow')
|
|
156
|
+
.needsData('php')
|
|
157
|
+
.severity('warn')
|
|
158
|
+
.message('FormRequest sem rules() preenchido: valida nada; o endpoint parece protegido por contrato e não é')
|
|
159
|
+
.assert((ctx) => {
|
|
160
|
+
const m = model(ctx);
|
|
161
|
+
if (!m)
|
|
162
|
+
return [];
|
|
163
|
+
const out = [];
|
|
164
|
+
for (const f of m.files) {
|
|
165
|
+
for (const fr of f.formRequests ?? []) {
|
|
166
|
+
if (!fr.hasRules) {
|
|
167
|
+
out.push({
|
|
168
|
+
target: `${f.path}:${fr.line} ${fr.class} sem rules()`,
|
|
169
|
+
location: `${f.path}:${fr.line}`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
})
|
|
176
|
+
.build();
|
|
177
|
+
/**
|
|
178
|
+
* I3 / R2.9–R2.11 — SQL bruto com interpolação/concatenação de variável é
|
|
179
|
+
* injeção; SQL bruto estático ou com bindings (`?`/named) é aceitável.
|
|
180
|
+
*
|
|
181
|
+
* False-positive guard (R2.11): interpolação de constante `self::`/`::CONST`
|
|
182
|
+
* e `config(...)` não é input externo — o extractor só marca T_VARIABLE, e
|
|
183
|
+
* constantes não são variáveis. Calibrado por fixture.
|
|
184
|
+
*/
|
|
185
|
+
export const noRawQueries = rule('no-raw-queries')
|
|
186
|
+
.needsData('php')
|
|
187
|
+
.severity('error')
|
|
188
|
+
.message('SQL bruto interpolado ou sem bindings (DB::raw/whereRaw/selectRaw/DB::select...): injeção; use bindings (`?` / named placeholders)')
|
|
189
|
+
.assert((ctx) => {
|
|
190
|
+
const m = model(ctx);
|
|
191
|
+
if (!m)
|
|
192
|
+
return [];
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const f of m.files) {
|
|
195
|
+
for (const q of f.rawQueries ?? []) {
|
|
196
|
+
const staticNoBindings = (q.call === 'DB::select' || q.call === 'DB::statement') && !q.hasBindings && !q.interpolated;
|
|
197
|
+
if (!q.interpolated && !staticNoBindings)
|
|
198
|
+
continue;
|
|
199
|
+
out.push({
|
|
200
|
+
target: `${f.path}:${q.line} ${q.call} ${q.interpolated ? 'interpolado' : 'sem bindings'}`,
|
|
201
|
+
location: `${f.path}:${q.line}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
})
|
|
207
|
+
.build();
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// F1 — Isolamento de oráculo em PHP
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
/**
|
|
212
|
+
* R3.1 — `use App\...` dentro de layout.oracle: o teste importa a unidade que
|
|
213
|
+
* deveria fiscalizar de fora. É espelho, não oráculo.
|
|
214
|
+
*
|
|
215
|
+
* Anti-burla (R3.3 / spec §6.4 / card F1): `app(Service::class)`,
|
|
216
|
+
* `resolve(...)`, `->make(...)`, `Container::make/get(...)` são IMPORT
|
|
217
|
+
* DINÂMICO — o teste que não escreveu `use App\...` ainda alcança a unidade.
|
|
218
|
+
* O extractor emite a aresta `serviceLocator` (método + arquivo inteiro);
|
|
219
|
+
* cada FQCN sob `App\` no oráculo é finding, exatamente como o `use` estático.
|
|
220
|
+
* Sem isto, o service-locator é a burla que vira a lei de cabeça para baixo.
|
|
221
|
+
*/
|
|
222
|
+
export function makeOracleNoSrc(oracleDirs) {
|
|
223
|
+
return rule('oracle-no-src')
|
|
224
|
+
.needsData('php')
|
|
225
|
+
.severity('error')
|
|
226
|
+
.message('teste de oráculo importa App\\ (unidade interna) — por `use` estático OU por service-locator `app(FQCN::class)` (import dinâmico): é espelho, não oráculo; exercite a rota via kernel HTTP e derive a expectativa do contrato')
|
|
227
|
+
.assert((ctx) => {
|
|
228
|
+
const m = model(ctx);
|
|
229
|
+
if (!m)
|
|
230
|
+
return [];
|
|
231
|
+
const out = [];
|
|
232
|
+
for (const f of m.files) {
|
|
233
|
+
if (!isOracleFile(f.path, oracleDirs))
|
|
234
|
+
continue;
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
// 1. `use App\...` estático (por método, como antes).
|
|
237
|
+
for (const t of f.tests ?? []) {
|
|
238
|
+
for (const meth of t.methods) {
|
|
239
|
+
for (const fqcn of meth.importsApp) {
|
|
240
|
+
if (seen.has(fqcn))
|
|
241
|
+
continue;
|
|
242
|
+
seen.add(fqcn);
|
|
243
|
+
out.push({ target: `${f.path} -> ${fqcn}`, location: f.path });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// 2. Anti-burla: service-locator com FQCN sob App\ (método + arquivo).
|
|
248
|
+
const locators = [
|
|
249
|
+
...(f.serviceLocator ?? []),
|
|
250
|
+
...(f.tests ?? []).flatMap((t) => t.methods.flatMap((meth) => meth.serviceLocator)),
|
|
251
|
+
];
|
|
252
|
+
for (const loc of locators) {
|
|
253
|
+
if (!/^App(\\|$)/.test(loc.fqcn))
|
|
254
|
+
continue;
|
|
255
|
+
const key = `locator:${loc.fqcn}`;
|
|
256
|
+
if (seen.has(key))
|
|
257
|
+
continue;
|
|
258
|
+
seen.add(key);
|
|
259
|
+
out.push({
|
|
260
|
+
target: `${f.path}:${loc.line} -> ${loc.fqcn} (service-locator)`,
|
|
261
|
+
location: `${f.path}:${loc.line}`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
})
|
|
267
|
+
.build();
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* R3.2 — fronteira reversa: `app/**` importando `tests/**` (espelho indireto
|
|
271
|
+
* vazando para produção). O core resolve só imports TS; o extractor PHP dá a
|
|
272
|
+
* aresta (`imports[].fqcn`), e esta regra espelha a do core TS e a do plugin
|
|
273
|
+
* python (srcNoOracle): arquivo sob `srcDirs` cujo import resolve para um
|
|
274
|
+
* namespace de teste (`Tests\...`) é finding.
|
|
275
|
+
*/
|
|
276
|
+
export function makeSrcNoOracle(srcDirs, testNamespaces) {
|
|
277
|
+
return rule('src-no-oracle')
|
|
278
|
+
.needsData('php')
|
|
279
|
+
.severity('error')
|
|
280
|
+
.message('implementação Laravel importa código de teste (use Tests\\...): fixture de teste vazando para produção')
|
|
281
|
+
.assert((ctx) => {
|
|
282
|
+
const m = model(ctx);
|
|
283
|
+
if (!m)
|
|
284
|
+
return [];
|
|
285
|
+
const out = [];
|
|
286
|
+
for (const f of m.files) {
|
|
287
|
+
if (!isUnderAnyDir(f.path, srcDirs))
|
|
288
|
+
continue;
|
|
289
|
+
for (const imp of f.imports ?? []) {
|
|
290
|
+
const ns = imp.fqcn.split('\\')[0] ?? '';
|
|
291
|
+
if (!testNamespaces.some((t) => t.toLowerCase() === ns.toLowerCase()))
|
|
292
|
+
continue;
|
|
293
|
+
out.push({
|
|
294
|
+
target: `${f.path}:${imp.line} -> ${imp.fqcn}`,
|
|
295
|
+
location: `${f.path}:${imp.line}`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
})
|
|
301
|
+
.build();
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* R3.3 / I4 — a regra-assinatura do plugin: dentro do oráculo,
|
|
305
|
+
* `withoutMiddleware()` remove e `actingAs()` forja exatamente a camada de
|
|
306
|
+
* autorização que I1 fiscaliza. O teste passa porque a proteção foi
|
|
307
|
+
* desligada, não porque a autorização funciona.
|
|
308
|
+
*
|
|
309
|
+
* Fronteira honesta: `actingAs` para obter token via fluxo real de login é
|
|
310
|
+
* aceitável (spec §4.2); o MVP marca o sinal e o alvo nomeia o método — o
|
|
311
|
+
* waiver existe para o caso legítimo, nunca o skip silencioso.
|
|
312
|
+
*/
|
|
313
|
+
export function makeOracleNoAuthForge(oracleDirs) {
|
|
314
|
+
return rule('oracle-no-auth-forge')
|
|
315
|
+
.needsData('php')
|
|
316
|
+
.severity('error')
|
|
317
|
+
.message('oráculo forja/desliga autorização (withoutMiddleware/actingAs): mock circular da camada que a invariante fiscaliza; use credencial semeada + guard real')
|
|
318
|
+
.assert((ctx) => {
|
|
319
|
+
const m = model(ctx);
|
|
320
|
+
if (!m)
|
|
321
|
+
return [];
|
|
322
|
+
const out = [];
|
|
323
|
+
for (const f of m.files) {
|
|
324
|
+
if (!isOracleFile(f.path, oracleDirs))
|
|
325
|
+
continue;
|
|
326
|
+
for (const t of f.tests ?? []) {
|
|
327
|
+
for (const meth of t.methods) {
|
|
328
|
+
if (!meth.withoutMiddleware && !meth.actingAs)
|
|
329
|
+
continue;
|
|
330
|
+
const how = [
|
|
331
|
+
meth.withoutMiddleware ? 'withoutMiddleware()' : null,
|
|
332
|
+
meth.actingAs ? 'actingAs()' : null,
|
|
333
|
+
].filter(Boolean).join(' + ');
|
|
334
|
+
out.push({
|
|
335
|
+
target: `${f.path}:${meth.line} ${t.class}.${meth.name} ${how}`,
|
|
336
|
+
location: `${f.path}:${meth.line}`,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
})
|
|
343
|
+
.build();
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* R3.4 / I5 — fakes de container dentro do oráculo substituem unidades por
|
|
347
|
+
* crença do agente: espelho por construção.
|
|
348
|
+
*/
|
|
349
|
+
export function makeOracleNoContainerFake(oracleDirs) {
|
|
350
|
+
return rule('oracle-no-container-fake')
|
|
351
|
+
.needsData('php')
|
|
352
|
+
.severity('error')
|
|
353
|
+
.message('oráculo usa fake/mock/swap de container (mock/partialMock/swap/fake/Container::set): expectativa derivada da crença sobre a unidade')
|
|
354
|
+
.assert((ctx) => {
|
|
355
|
+
const m = model(ctx);
|
|
356
|
+
if (!m)
|
|
357
|
+
return [];
|
|
358
|
+
const out = [];
|
|
359
|
+
for (const f of m.files) {
|
|
360
|
+
if (!isOracleFile(f.path, oracleDirs))
|
|
361
|
+
continue;
|
|
362
|
+
for (const t of f.tests ?? []) {
|
|
363
|
+
for (const meth of t.methods) {
|
|
364
|
+
if (meth.containerFakes.length === 0)
|
|
365
|
+
continue;
|
|
366
|
+
out.push({
|
|
367
|
+
target: `${f.path}:${meth.line} ${t.class}.${meth.name} ${meth.containerFakes.join('/')}`,
|
|
368
|
+
location: `${f.path}:${meth.line}`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return out;
|
|
374
|
+
})
|
|
375
|
+
.build();
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* I6 / R3.5 — teste cujo corpo é só mock + assertCalled, sem asserção de
|
|
379
|
+
* domínio, é teste-espelho: verifica que o mock foi chamado, não que o
|
|
380
|
+
* negócio aconteceu. `warn` no MVP (heurística de corpo é frágil).
|
|
381
|
+
*/
|
|
382
|
+
export const testMockOnly = rule('test-mock-only')
|
|
383
|
+
.needsData('php')
|
|
384
|
+
.severity('warn')
|
|
385
|
+
.message('teste só verifica que o mock foi chamado (expects/assertCalled) sem asserção de domínio: teste-espelho')
|
|
386
|
+
.assert((ctx) => {
|
|
387
|
+
const m = model(ctx);
|
|
388
|
+
if (!m)
|
|
389
|
+
return [];
|
|
390
|
+
const out = [];
|
|
391
|
+
for (const f of m.files) {
|
|
392
|
+
for (const t of f.tests ?? []) {
|
|
393
|
+
for (const meth of t.methods) {
|
|
394
|
+
if (!meth.mockOnly)
|
|
395
|
+
continue;
|
|
396
|
+
out.push({
|
|
397
|
+
target: `${f.path}:${meth.line} ${t.class}.${meth.name}`,
|
|
398
|
+
location: `${f.path}:${meth.line}`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return out;
|
|
404
|
+
})
|
|
405
|
+
.build();
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// Higiene do extractor (R1.3/R1.4) — nunca silencioso
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
/**
|
|
410
|
+
* R1.3 — arquivo que o tokenizador não leu (sintaxe ilegível) é finding
|
|
411
|
+
* `error`, nunca skip silencioso: gate que pula mudo reporta mais saúde do
|
|
412
|
+
* que existe.
|
|
413
|
+
*/
|
|
414
|
+
export const phpParseError = rule('php-parse-error')
|
|
415
|
+
.needsData('php')
|
|
416
|
+
.severity('error')
|
|
417
|
+
.message('arquivo PHP não tokenizado pelo extractor (sintaxe ilegível): o gate não fiscalizou este arquivo')
|
|
418
|
+
.assert((ctx) => {
|
|
419
|
+
const m = model(ctx);
|
|
420
|
+
if (!m)
|
|
421
|
+
return [];
|
|
422
|
+
const out = [];
|
|
423
|
+
for (const f of m.files) {
|
|
424
|
+
if (!f.error)
|
|
425
|
+
continue;
|
|
426
|
+
out.push({ target: `${f.path} (${f.error})`, location: f.path });
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
})
|
|
430
|
+
.build();
|
|
431
|
+
/**
|
|
432
|
+
* R1.4 — `php` ausente no host: o extractor retornou null. O gate NÃO pode
|
|
433
|
+
* fingir que fiscalizou PHP: finding `warn` nomeia o runtime ausente e o
|
|
434
|
+
* `prumo doctor`/CI deve instalar o binário. Skip silencioso é a falsa
|
|
435
|
+
* sensação que a spec §8 lista como risco grave.
|
|
436
|
+
*/
|
|
437
|
+
export const phpRuntimeMissing = rule('php-runtime-missing')
|
|
438
|
+
.needsData('php')
|
|
439
|
+
.severity('warn')
|
|
440
|
+
.message('binário `php` ausente no host: invariantes PHP/Laravel não rodaram; instale PHP >= 8.1 no CI (skip NÃO é silencioso)')
|
|
441
|
+
.assert((ctx) => {
|
|
442
|
+
const m = ctx.get('php');
|
|
443
|
+
if (m !== null)
|
|
444
|
+
return [];
|
|
445
|
+
return [{ target: 'php ausente no PATH' }];
|
|
446
|
+
})
|
|
447
|
+
.build();
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Plugin
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
function buildRules(opts) {
|
|
452
|
+
// As regras capturam a config via closure (factory): o RuleContext do core
|
|
453
|
+
// não expõe a config do consumidor, então as opções (spec §5.2) entram pelo
|
|
454
|
+
// factory `phpLaravel({ oracleDirs, authSymbols, publicSymbols, ... })`.
|
|
455
|
+
return [
|
|
456
|
+
makeMutationRoutesGuarded(opts.authSymbols, opts.publicSymbols),
|
|
457
|
+
formRequestValidated,
|
|
458
|
+
formRequestHollow,
|
|
459
|
+
noRawQueries,
|
|
460
|
+
makeOracleNoSrc(opts.oracleDirs),
|
|
461
|
+
makeSrcNoOracle(opts.srcDirs, opts.testNamespaces),
|
|
462
|
+
makeOracleNoAuthForge(opts.oracleDirs),
|
|
463
|
+
makeOracleNoContainerFake(opts.oracleDirs),
|
|
464
|
+
testMockOnly,
|
|
465
|
+
phpParseError,
|
|
466
|
+
phpRuntimeMissing,
|
|
467
|
+
];
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* O plugin oficial PHP/Laravel do Prumo.
|
|
471
|
+
*
|
|
472
|
+
* Analisa, nunca executa: o subprocesso PHP só tokeniza (stdlib
|
|
473
|
+
* `token_get_all`); nenhuma linha do código analisado roda. `php` ausente →
|
|
474
|
+
* skip honesto com finding `php-runtime-missing`, nunca gate mudo.
|
|
475
|
+
*/
|
|
476
|
+
export function phpLaravel(options = {}) {
|
|
477
|
+
const opts = {
|
|
478
|
+
oracleDirs: options.oracleDirs ?? DEFAULT_ORACLE_DIRS,
|
|
479
|
+
authSymbols: options.authSymbols ?? [],
|
|
480
|
+
publicSymbols: options.publicSymbols ?? [],
|
|
481
|
+
srcDirs: options.srcDirs ?? ['app'],
|
|
482
|
+
testNamespaces: options.testNamespaces ?? ['Tests'],
|
|
483
|
+
};
|
|
484
|
+
return definePlugin({
|
|
485
|
+
name: 'php-laravel',
|
|
486
|
+
version: pkg.version,
|
|
487
|
+
description: 'PHP/Laravel invariants: guarded mutation routes, FormRequest discipline, parameterized SQL, oracle isolation (no auth forge, no container fakes, anti-burla service-locator).',
|
|
488
|
+
extractors: {
|
|
489
|
+
php: async (ctx) => {
|
|
490
|
+
try {
|
|
491
|
+
return await extractPhpModel(ctx.root);
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
// timeout/JSON inválido: modelo vazio com erro marcado — as regras
|
|
495
|
+
// de app ficam mudas, php-parse-error fala (nunca silencioso).
|
|
496
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
497
|
+
return { version: 1, files: [{ path: '(extractor)', kind: 'other', error: msg }] };
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
rules: buildRules(opts),
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/** Preset com a convenção padrão de oráculo (usa em invariants/rules.ts). */
|
|
505
|
+
export const phpLaravelPreset = phpLaravel().rules;
|
|
506
|
+
export default phpLaravel;
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ihelpee/prumo-plugin-php-laravel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "PHP/Laravel plugin for Prumo: guarded mutation routes, FormRequest discipline, parameterized SQL, and oracle isolation invariants.",
|
|
6
|
+
"author": "Alex Gotardi <alexgotardi@gmail.com> (https://github.com/alexgotardi)",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"php",
|
|
19
|
+
"package.json",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"license": "Apache-2.0",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/ihelpee/prumo.git"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20.11",
|
|
30
|
+
"php": ">=8.1"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@ihelpee/prumo": "0.9.0"
|
|
37
|
+
}
|
|
6
38
|
}
|