@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/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }