@ihelpee/prumo-plugin-python-fastapi 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 +108 -2
- package/dist/ast.d.ts +84 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +90 -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 +30 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +426 -0
- package/package.json +35 -3
- package/python/ast_extractor.py +439 -0
package/dist/rules.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
2
|
+
import { rule, definePlugin, } from '@ihelpee/prumo/invariants';
|
|
3
|
+
import { extractPythonModel } from './ast.js';
|
|
4
|
+
/**
|
|
5
|
+
* Invariantes Python/FastAPI (spec `docs/especificacao-prumo-python.md` §2/§3).
|
|
6
|
+
*
|
|
7
|
+
* A lei fica 100% aqui (core Node). O subprocesso Python é parser, não
|
|
8
|
+
* oráculo: serializa AST em JSON e decide zero. Cada regra declara o que NÃO
|
|
9
|
+
* pega (fronteiras honestas, Art. IV).
|
|
10
|
+
*/
|
|
11
|
+
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
12
|
+
const DEFAULT_AUTH_SYMBOLS = [
|
|
13
|
+
'get_current_user',
|
|
14
|
+
'current_user',
|
|
15
|
+
'get_current_active_user',
|
|
16
|
+
'require_auth',
|
|
17
|
+
'auth_required',
|
|
18
|
+
];
|
|
19
|
+
const SHARED = new Set(['common', 'shared', 'core', 'config', 'database', 'schemas', 'models', 'deps', 'dependencies', 'auth']);
|
|
20
|
+
const MONETARY_NAME = /(price|amount|total|cost|balance|salary|subtotal|discount|fee)/i;
|
|
21
|
+
function model(ctx) {
|
|
22
|
+
return ((ctx.get('python') ?? {
|
|
23
|
+
version: 1,
|
|
24
|
+
files: [],
|
|
25
|
+
pythonAvailable: false,
|
|
26
|
+
timedOut: false,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* R1.4 — python3 ausente: as regras de domínio viram no-op (skip com motivo
|
|
31
|
+
* vive em `python-parse-error`, que é a única que reporta a indisponibilidade
|
|
32
|
+
* via erro de regra — visível em `erroredRules`, nunca crash do gate).
|
|
33
|
+
*/
|
|
34
|
+
function modelIfAvailable(ctx) {
|
|
35
|
+
const m = model(ctx);
|
|
36
|
+
return m.pythonAvailable ? m : null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* R1.3/R1.4/R1.5 — o parser nunca é silencioso.
|
|
40
|
+
* SyntaxError vira finding `error`; timeout vira finding próprio;
|
|
41
|
+
* `python3` ausente vira finding `warn` com motivo (a lei não foi
|
|
42
|
+
* verificada: aparece, conta, é promovível — e `--fail-on-warn` bloqueia).
|
|
43
|
+
* As demais regras viram no-op nesse caso (skip com motivo, nunca crash).
|
|
44
|
+
*/
|
|
45
|
+
const pythonParseHealth = rule('python-parse-error')
|
|
46
|
+
.needsData('python')
|
|
47
|
+
.message('arquivo Python não pôde ser parseado (SyntaxError) ou o parser não rodou — invariante não verificado')
|
|
48
|
+
.assert((ctx) => {
|
|
49
|
+
const m = model(ctx);
|
|
50
|
+
if (!m.pythonAvailable) {
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
target: `parser Python indisponível: ${m.reason ?? 'motivo desconhecido'} — invariantes Python não verificados`,
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const f of m.files) {
|
|
59
|
+
if (f.error === 'syntax') {
|
|
60
|
+
out.push({ target: `${f.path} (SyntaxError)`, location: f.path });
|
|
61
|
+
}
|
|
62
|
+
else if (f.error === 'unreadable') {
|
|
63
|
+
out.push({ target: `${f.path} (ilegível)`, location: f.path });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (m.timedOut) {
|
|
67
|
+
out.push({ target: 'python-parse-timeout (subprocesso morto no timeout — parse patológico?)' });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
})
|
|
71
|
+
.build();
|
|
72
|
+
/** Dependências de router resolvidas: definição `APIRouter(...)` no próprio arquivo (R3.3). */
|
|
73
|
+
function routerLevelDeps(file) {
|
|
74
|
+
const deps = new Map();
|
|
75
|
+
for (const r of file.routers ?? []) {
|
|
76
|
+
deps.set(r.var, [...(deps.get(r.var) ?? []), ...r.dependencies]);
|
|
77
|
+
}
|
|
78
|
+
for (const inc of file.include_routers ?? []) {
|
|
79
|
+
if (inc.dependencies.length === 0)
|
|
80
|
+
continue;
|
|
81
|
+
const varName = inc.target.split('.').pop() ?? inc.target;
|
|
82
|
+
deps.set(varName, [...(deps.get(varName) ?? []), ...inc.dependencies]);
|
|
83
|
+
}
|
|
84
|
+
return deps;
|
|
85
|
+
}
|
|
86
|
+
const fileModule = (path) => path.replace(/\.py$/, '').replace(/\//g, '.');
|
|
87
|
+
/**
|
|
88
|
+
* R3.3 cross-file: `app.include_router(billing_router, dependencies=[...])`
|
|
89
|
+
* em `main.py` protege as rotas declaradas em OUTRO arquivo
|
|
90
|
+
* (`app/billing/router.py`). Resolve o alias do import (`import router as
|
|
91
|
+
* billing_router`) para o módulo de origem e propaga as dependências para o
|
|
92
|
+
* módulo do router — sem isto, a camada que mais existe na prática seria
|
|
93
|
+
* cega e a regra geraria falso-positivo em todo app FastAPI idiomático.
|
|
94
|
+
*/
|
|
95
|
+
function moduleRouterDeps(m) {
|
|
96
|
+
const deps = new Map();
|
|
97
|
+
for (const file of m.files) {
|
|
98
|
+
const local = routerLevelDeps(file);
|
|
99
|
+
const localModule = fileModule(file.path);
|
|
100
|
+
for (const [varName, list] of local) {
|
|
101
|
+
// dependências definidas na própria arquivo valem para o módulo.
|
|
102
|
+
deps.set(localModule, [...(deps.get(localModule) ?? []), ...list]);
|
|
103
|
+
}
|
|
104
|
+
for (const inc of file.include_routers ?? []) {
|
|
105
|
+
if (inc.dependencies.length === 0)
|
|
106
|
+
continue;
|
|
107
|
+
const alias = inc.target.split('.').pop() ?? inc.target;
|
|
108
|
+
// alias → módulo de origem via imports do arquivo que monta.
|
|
109
|
+
let sourceModule = null;
|
|
110
|
+
for (const imp of file.imports ?? []) {
|
|
111
|
+
if (imp.names.includes(alias)) {
|
|
112
|
+
sourceModule = imp.module;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// alias local (mesmo arquivo) ou montagem de app raiz: mantém por módulo atual.
|
|
117
|
+
const key = sourceModule ?? localModule;
|
|
118
|
+
deps.set(key, [...(deps.get(key) ?? []), ...inc.dependencies]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return deps;
|
|
122
|
+
}
|
|
123
|
+
function routesOf(m) {
|
|
124
|
+
const byModule = moduleRouterDeps(m);
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const file of m.files) {
|
|
127
|
+
const moduleDeps = byModule.get(fileModule(file.path)) ?? [];
|
|
128
|
+
for (const route of file.routes ?? []) {
|
|
129
|
+
out.push({ file, route, routerDeps: moduleDeps });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* R3.1–R3.4 — rota de mutação sem auth em NENHUMA camada:
|
|
136
|
+
* Depends de auth no handler, dependencies no decorator, nem camada de
|
|
137
|
+
* router (APIRouter/include_router — a que mais gera falso-negativo).
|
|
138
|
+
* Não pega: rota de leitura; rota marcada pública via publicSymbols.
|
|
139
|
+
*/
|
|
140
|
+
const mutationRoutesGuarded = (opts) => rule('mutation-routes-guarded')
|
|
141
|
+
.needsData('python')
|
|
142
|
+
.message('rota de mutação FastAPI sem Depends de auth no handler, no decorator nem na camada do router (a ausência é invisível: a rota funciona sem token)')
|
|
143
|
+
.assert((ctx) => {
|
|
144
|
+
const m = modelIfAvailable(ctx);
|
|
145
|
+
if (!m)
|
|
146
|
+
return [];
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const { file, route, routerDeps } of routesOf(m)) {
|
|
149
|
+
if (!MUTATING.has(route.method))
|
|
150
|
+
continue;
|
|
151
|
+
const allDeps = [...route.param_depends, ...route.router_dependencies, ...routerDeps];
|
|
152
|
+
if (allDeps.some((d) => opts.publicSymbols.includes(d)))
|
|
153
|
+
continue;
|
|
154
|
+
if (allDeps.some((d) => opts.authSymbols.includes(d)))
|
|
155
|
+
continue;
|
|
156
|
+
out.push({
|
|
157
|
+
target: `${route.method} ${route.path} (${route.handler})`,
|
|
158
|
+
location: `${file.path}:${route.line}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
})
|
|
163
|
+
.build();
|
|
164
|
+
/**
|
|
165
|
+
* R3.5–R3.7 — Pydantic model usado como BODY de rota com campo sem
|
|
166
|
+
* constraint de domínio nem validator. `Optional` sem default não é
|
|
167
|
+
* violação (opcional legítimo). Fronteira honesta: response_model não é
|
|
168
|
+
* alvo — serialização de saída não valida input.
|
|
169
|
+
*
|
|
170
|
+
* R3.6 (downgrade v1) — o extractor serializa `pydantic_v1` por arquivo
|
|
171
|
+
* (import `pydantic.v1`/`pydantic.v1.*`). A spec exige que Pydantic v1
|
|
172
|
+
* (`@validator`, `Field(regex=...)`) seja `warn`, não `error`: v1 está
|
|
173
|
+
* deprecado e a equipe pode estar em migração. Como a severidade é por
|
|
174
|
+
* regra (não por alvo), a lei é espelhada em duas regras irmãs com o mesmo
|
|
175
|
+
* predicado, particionadas pelo flag do arquivo onde o MODEL está definido
|
|
176
|
+
* (é lá que vivem `@validator`/`Field(regex=...)`):
|
|
177
|
+
* - `dto-has-validation` → arquivos v2 (error)
|
|
178
|
+
* - `dto-has-validation-pydantic-v1` → arquivos v1 (warn)
|
|
179
|
+
* O downgrade é keyed no arquivo do model, não no da rota: a constraint
|
|
180
|
+
* ausente é um fato do model.
|
|
181
|
+
*/
|
|
182
|
+
function dtoHasValidationFor(pydanticV1) {
|
|
183
|
+
return rule(pydanticV1 ? 'dto-has-validation-pydantic-v1' : 'dto-has-validation')
|
|
184
|
+
.severity(pydanticV1 ? 'warn' : 'error')
|
|
185
|
+
.needsData('python')
|
|
186
|
+
.message(pydanticV1
|
|
187
|
+
? 'modelo Pydantic v1 (import pydantic.v1) usado como body de rota sem Field(...)/constraint/validator no campo: v1 está deprecado — downgrade para warn (R3.6); migre para v2 e a ausência volta a ser error'
|
|
188
|
+
: 'modelo Pydantic usado como body de rota sem Field(...)/Annotated/constraint/validator no campo: o FastAPI valida o que existe e ignora o que falta')
|
|
189
|
+
.assert((ctx) => {
|
|
190
|
+
const m = modelIfAvailable(ctx);
|
|
191
|
+
if (!m)
|
|
192
|
+
return [];
|
|
193
|
+
const bodyModels = new Set();
|
|
194
|
+
for (const file of m.files) {
|
|
195
|
+
for (const r of file.routes ?? []) {
|
|
196
|
+
if (r.body_model)
|
|
197
|
+
bodyModels.add(r.body_model);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const out = [];
|
|
201
|
+
for (const file of m.files) {
|
|
202
|
+
// Partição R3.6: cada regra irmã vê apenas os arquivos da sua versão.
|
|
203
|
+
if (Boolean(file.pydantic_v1) !== pydanticV1)
|
|
204
|
+
continue;
|
|
205
|
+
for (const mod of file.models ?? []) {
|
|
206
|
+
if (!bodyModels.has(mod.name))
|
|
207
|
+
continue;
|
|
208
|
+
const bad = mod.fields.filter((f) => !f.has_constraint && !f.has_validator);
|
|
209
|
+
if (bad.length === 0)
|
|
210
|
+
continue;
|
|
211
|
+
out.push({
|
|
212
|
+
target: `${mod.name}.${bad.map((f) => f.name).join(', ')}`,
|
|
213
|
+
location: `${file.path}:${mod.line}`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
})
|
|
219
|
+
.build();
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* R3.8 — rota de mutação sem response_model: o handler devolve o ORM/dict
|
|
223
|
+
* cru e vaza campos internos (password_hash). Warn: heurística de contrato.
|
|
224
|
+
*/
|
|
225
|
+
const routeHasResponseModel = rule('route-has-response-model')
|
|
226
|
+
.severity('warn')
|
|
227
|
+
.needsData('python')
|
|
228
|
+
.message('rota de mutação sem response_model: risco de vazar o ORM/dict cru (password_hash) na resposta')
|
|
229
|
+
.assert((ctx) => {
|
|
230
|
+
const m = modelIfAvailable(ctx);
|
|
231
|
+
if (!m)
|
|
232
|
+
return [];
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const { file, route } of routesOf(m)) {
|
|
235
|
+
if (!MUTATING.has(route.method))
|
|
236
|
+
continue;
|
|
237
|
+
if (route.response_model)
|
|
238
|
+
continue;
|
|
239
|
+
out.push({
|
|
240
|
+
target: `${route.method} ${route.path} (${route.handler})`,
|
|
241
|
+
location: `${file.path}:${route.line}`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
})
|
|
246
|
+
.build();
|
|
247
|
+
function featureOfModule(module, packageRoot) {
|
|
248
|
+
const parts = module.split('.');
|
|
249
|
+
if (parts[0] !== packageRoot)
|
|
250
|
+
return null;
|
|
251
|
+
const feature = parts[1];
|
|
252
|
+
if (!feature || SHARED.has(feature))
|
|
253
|
+
return null;
|
|
254
|
+
return feature;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* R3.9 — `app/<featureA>/` importando `app/<featureB>/` (fronteira de
|
|
258
|
+
* contexto). Warn no MVP: layouts Python variam demais.
|
|
259
|
+
*/
|
|
260
|
+
const noCrossFeatureImport = (opts) => rule('no-cross-feature-import')
|
|
261
|
+
.severity('warn')
|
|
262
|
+
.needsData('python')
|
|
263
|
+
.message('import entre features do pacote raiz (fronteira de contexto atravessada)')
|
|
264
|
+
.assert((ctx) => {
|
|
265
|
+
const m = modelIfAvailable(ctx);
|
|
266
|
+
if (!m)
|
|
267
|
+
return [];
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const file of m.files) {
|
|
270
|
+
const fileModule = file.path.replace(/\.py$/, '').replace(/\//g, '.');
|
|
271
|
+
// Composition root cabula features de propósito: entrypoint é isento.
|
|
272
|
+
if (opts.entrypoints.includes(fileModule))
|
|
273
|
+
continue;
|
|
274
|
+
const fromFeature = featureOfModule(fileModule, opts.packageRoot);
|
|
275
|
+
for (const imp of file.imports ?? []) {
|
|
276
|
+
const toFeature = featureOfModule(imp.module, opts.packageRoot);
|
|
277
|
+
if (!fromFeature || !toFeature || fromFeature === toFeature)
|
|
278
|
+
continue;
|
|
279
|
+
out.push({ target: `${file.path}:${imp.line} -> ${imp.module}`, location: file.path });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
})
|
|
284
|
+
.build();
|
|
285
|
+
/**
|
|
286
|
+
* R3.10 — campo monetário como `float`. `Decimal` é aceitável; `float` é
|
|
287
|
+
* o anti-padrão (erro de arredondamento binário em dinheiro).
|
|
288
|
+
*/
|
|
289
|
+
const monetaryIsFloat = rule('monetary-is-integer')
|
|
290
|
+
.severity('warn')
|
|
291
|
+
.needsData('python')
|
|
292
|
+
.message('campo monetário tipado como float — use Decimal ou inteiro em centavos')
|
|
293
|
+
.assert((ctx) => {
|
|
294
|
+
const m = modelIfAvailable(ctx);
|
|
295
|
+
if (!m)
|
|
296
|
+
return [];
|
|
297
|
+
const out = [];
|
|
298
|
+
for (const file of m.files) {
|
|
299
|
+
for (const mod of file.models ?? []) {
|
|
300
|
+
for (const f of mod.fields) {
|
|
301
|
+
if (!MONETARY_NAME.test(f.name))
|
|
302
|
+
continue;
|
|
303
|
+
if (!/^(float|double)$/.test(f.annotation.toLowerCase()))
|
|
304
|
+
continue;
|
|
305
|
+
out.push({ target: `${mod.name}.${f.name}: ${f.annotation}`, location: `${file.path}:${mod.line}` });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
})
|
|
311
|
+
.build();
|
|
312
|
+
/** Aresta de import Python (estática + dinâmica anti-burla) como string de módulo. */
|
|
313
|
+
function moduleEdges(file) {
|
|
314
|
+
const out = [];
|
|
315
|
+
for (const imp of file.imports ?? [])
|
|
316
|
+
out.push({ module: imp.module, line: imp.line, dynamic: false });
|
|
317
|
+
for (const dyn of file.dynamic_imports ?? [])
|
|
318
|
+
out.push({ module: dyn.module, line: dyn.line, dynamic: true });
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
const startsWithModule = (module, prefix) => module === prefix || module.startsWith(prefix + '.');
|
|
322
|
+
const dirToModulePrefix = (dir) => dir.replace(/\\/g, '/').replace(/\/+$/, '').replace(/\/__init__.*$/, '').replace(/\//g, '.');
|
|
323
|
+
/**
|
|
324
|
+
* A LEI, traduzida para Python (§2): `tests/oracle/` é proibido de importar
|
|
325
|
+
* as unidades de `app/`. Cobre absolute (R2.1), plain (R2.2), relative
|
|
326
|
+
* resolvido (R2.3) e dynamic importlib/__import__ (R2.4 — sem isto o agente
|
|
327
|
+
* burla o gate trocando import por importlib). Entrypoint/composition root
|
|
328
|
+
* (ex.: `app.main`) é permitido: oráculo black-box sobe o app pelo
|
|
329
|
+
* entrypoint público, não importa a unidade sob teste.
|
|
330
|
+
*/
|
|
331
|
+
const oracleNoSrc = (opts) => rule('oracle-no-src')
|
|
332
|
+
.needsData('python')
|
|
333
|
+
.message('teste de oráculo importa unidade de produção: é espelho, não oráculo. Suba o app pelo entrypoint (ex.: app.main) e derive a expectativa do contrato')
|
|
334
|
+
.assert((ctx) => {
|
|
335
|
+
const m = modelIfAvailable(ctx);
|
|
336
|
+
if (!m)
|
|
337
|
+
return [];
|
|
338
|
+
const srcPrefixes = opts.srcDirs.map(dirToModulePrefix);
|
|
339
|
+
const oraclePrefixes = opts.oracleDirs.map(dirToModulePrefix);
|
|
340
|
+
const out = [];
|
|
341
|
+
for (const file of m.files) {
|
|
342
|
+
const fileModule = file.path.replace(/\.py$/, '').replace(/\//g, '.');
|
|
343
|
+
if (!oraclePrefixes.some((p) => fileModule === p || fileModule.startsWith(p + '.')))
|
|
344
|
+
continue;
|
|
345
|
+
for (const edge of moduleEdges(file)) {
|
|
346
|
+
if (!srcPrefixes.some((p) => startsWithModule(edge.module, p)))
|
|
347
|
+
continue;
|
|
348
|
+
if (opts.entrypoints.includes(edge.module))
|
|
349
|
+
continue;
|
|
350
|
+
out.push({
|
|
351
|
+
target: `${file.path}:${edge.line} -> ${edge.module}${edge.dynamic ? ' (dynamic import)' : ''}`,
|
|
352
|
+
location: file.path,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
})
|
|
358
|
+
.build();
|
|
359
|
+
/**
|
|
360
|
+
* R2.7 — fronteira reversa: produção proibida de importar fixture de teste
|
|
361
|
+
* (equivalente exato do `src-no-oracle` do core TS).
|
|
362
|
+
*/
|
|
363
|
+
const srcNoOracle = (opts) => rule('src-no-oracle')
|
|
364
|
+
.needsData('python')
|
|
365
|
+
.message('implementação importa fixture de teste: espelho indireto vazando para produção')
|
|
366
|
+
.assert((ctx) => {
|
|
367
|
+
const m = modelIfAvailable(ctx);
|
|
368
|
+
if (!m)
|
|
369
|
+
return [];
|
|
370
|
+
const srcPrefixes = opts.srcDirs.map(dirToModulePrefix);
|
|
371
|
+
const out = [];
|
|
372
|
+
for (const file of m.files) {
|
|
373
|
+
const fileModule = file.path.replace(/\.py$/, '').replace(/\//g, '.');
|
|
374
|
+
if (!srcPrefixes.some((p) => startsWithModule(fileModule, p)))
|
|
375
|
+
continue;
|
|
376
|
+
for (const edge of moduleEdges(file)) {
|
|
377
|
+
if (!opts.testRoots.some((t) => startsWithModule(edge.module, t)))
|
|
378
|
+
continue;
|
|
379
|
+
out.push({
|
|
380
|
+
target: `${file.path}:${edge.line} -> ${edge.module}`,
|
|
381
|
+
location: file.path,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
})
|
|
387
|
+
.build();
|
|
388
|
+
/**
|
|
389
|
+
* O Plugin oficial Python/FastAPI para o Prumo (spec §4.2: plugin npm,
|
|
390
|
+
* NUNCA pacote PyPI — a lei mora no core Node; o `.py` embutido é parser).
|
|
391
|
+
*
|
|
392
|
+
* Uso (spec §4.4 — `invariants:`, não o campo inerte `plugins:`):
|
|
393
|
+
*
|
|
394
|
+
* import { pythonFastapi } from '@ihelpee/prumo-plugin-python-fastapi';
|
|
395
|
+
* export default defineConfig({ invariants: [ pythonFastapi() ], ... });
|
|
396
|
+
*/
|
|
397
|
+
export function pythonFastapi(options = {}) {
|
|
398
|
+
const srcDirs = options.srcDirs ?? ['app'];
|
|
399
|
+
const oracleDirs = options.oracleDirs ?? ['tests/oracle'];
|
|
400
|
+
const testRoots = options.testRoots ?? [...new Set([...oracleDirs.map((d) => d.split('/')[0]), 'tests'])];
|
|
401
|
+
const packageRoot = options.packageRoot ?? srcDirs[0] ?? 'app';
|
|
402
|
+
const entrypoints = options.entrypoints ?? [`${packageRoot}.main`];
|
|
403
|
+
const authSymbols = options.authSymbols ?? DEFAULT_AUTH_SYMBOLS;
|
|
404
|
+
const publicSymbols = options.publicSymbols ?? [];
|
|
405
|
+
return definePlugin({
|
|
406
|
+
name: 'python-fastapi',
|
|
407
|
+
version: pkg.version,
|
|
408
|
+
description: 'Python/FastAPI invariants: mutation-route auth guards (Depends/router/include_router), Pydantic v2 body validation, oracle isolation (incl. importlib anti-burla).',
|
|
409
|
+
extractors: {
|
|
410
|
+
python: (ctx) => extractPythonModel(ctx.root),
|
|
411
|
+
},
|
|
412
|
+
rules: [
|
|
413
|
+
pythonParseHealth,
|
|
414
|
+
mutationRoutesGuarded({ authSymbols, publicSymbols }),
|
|
415
|
+
dtoHasValidationFor(false),
|
|
416
|
+
dtoHasValidationFor(true),
|
|
417
|
+
routeHasResponseModel,
|
|
418
|
+
noCrossFeatureImport({ packageRoot, entrypoints }),
|
|
419
|
+
monetaryIsFloat,
|
|
420
|
+
oracleNoSrc({ srcDirs, oracleDirs, entrypoints }),
|
|
421
|
+
srcNoOracle({ srcDirs, testRoots }),
|
|
422
|
+
],
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
/** Preset com defaults FastAPI canônicos (`app/`, `tests/oracle/`, entrypoint `app.main`). */
|
|
426
|
+
export const pythonFastapiPreset = pythonFastapi().rules;
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ihelpee/prumo-plugin-python-fastapi",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Python/FastAPI plugin for Prumo: mutation-route auth guards (Depends), Pydantic v2 DTO validation, and oracle isolation for pytest.",
|
|
6
|
+
"author": "Alex Gotardi <alexgotardi@gmail.com>",
|
|
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
|
+
"python",
|
|
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
|
+
"python": ">=3.9"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@ihelpee/prumo": "0.9.0"
|
|
37
|
+
}
|
|
6
38
|
}
|