@ihelpee/prumo-plugin-go-gin 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 +110 -2
- package/dist/ast.d.ts +154 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +123 -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 +113 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +718 -0
- package/go-extractor/cmd/prumo-go-extractor/main.go +596 -0
- package/go-extractor/go.mod +3 -0
- package/package.json +35 -3
package/dist/rules.js
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import { rule, definePlugin, } from '@ihelpee/prumo/invariants';
|
|
2
|
+
import { startsWithAny } from '@ihelpee/prumo/core';
|
|
3
|
+
import { runGoExtractor, DEFAULT_AUTH_SYMBOLS, DEFAULT_PUBLIC_SYMBOLS, DEFAULT_KNOWN_ERR_RETURNING, DEFAULT_ORACLE_DIRS, } from './ast.js';
|
|
4
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
5
|
+
/**
|
|
6
|
+
* Invariantes Go/Gin (spec `docs/especificacao-prumo-go-gin.md` §2 e §3).
|
|
7
|
+
*
|
|
8
|
+
* A LEI mora aqui, no core Node (DECISÃO §4.2 — anti-bifurcação): o
|
|
9
|
+
* extrator Go é só parser; a decisão "isto é violação?" é 100% daqui. Se a
|
|
10
|
+
* lógica de violação fosse reimplementada em Go, nasceria um segundo oráculo
|
|
11
|
+
* da mesma regra — e dois implementadores da mesma lei driftam, o que é
|
|
12
|
+
* auto-refutação para uma ferramenta que caça drift.
|
|
13
|
+
*
|
|
14
|
+
* Severidade segue a política do core: só `error` bloqueia; heurística é
|
|
15
|
+
* `warn` (promovível via `--fail-on-warn`). Cada regra declara o que NÃO
|
|
16
|
+
* pega (fronteira honesta, Art. IV).
|
|
17
|
+
*/
|
|
18
|
+
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
19
|
+
/** Bindings de BODY: sem tag é `error` (R3.12). */
|
|
20
|
+
const BODY_BINDINGS = new Set([
|
|
21
|
+
'ShouldBindJSON',
|
|
22
|
+
'BindJSON',
|
|
23
|
+
'ShouldBind',
|
|
24
|
+
'Bind',
|
|
25
|
+
'ShouldBindWith',
|
|
26
|
+
'BindWith',
|
|
27
|
+
'ShouldBindXML',
|
|
28
|
+
'BindXML',
|
|
29
|
+
'ShouldBindYAML',
|
|
30
|
+
'BindYAML',
|
|
31
|
+
'ShouldBindMsgPack',
|
|
32
|
+
'BindMsgPack',
|
|
33
|
+
]);
|
|
34
|
+
/** Bindings de QUERY/URI/HEADER/FORM: sem tag é `warn` (R3.12). */
|
|
35
|
+
const QUERY_BINDINGS = new Set([
|
|
36
|
+
'ShouldBindQuery',
|
|
37
|
+
'BindQuery',
|
|
38
|
+
'ShouldBindQueryWith',
|
|
39
|
+
'BindQueryWith',
|
|
40
|
+
'ShouldBindUri',
|
|
41
|
+
'BindUri',
|
|
42
|
+
'ShouldBindUriWith',
|
|
43
|
+
'BindUriWith',
|
|
44
|
+
'ShouldBindHeader',
|
|
45
|
+
'BindHeader',
|
|
46
|
+
'ShouldBindForm',
|
|
47
|
+
'BindForm',
|
|
48
|
+
]);
|
|
49
|
+
const MONETARY_NAME = /(amount|price|total|cost|value|balance|fee|valor|preco|custo|saldo)/i;
|
|
50
|
+
const FLOAT_TYPE = /^(float32|float64)$/;
|
|
51
|
+
function hasCentsSuffix(name) {
|
|
52
|
+
return /Cents$|Centavos$/i.test(name);
|
|
53
|
+
}
|
|
54
|
+
function goState(ctx) {
|
|
55
|
+
const raw = ctx.get('go');
|
|
56
|
+
if (raw && raw.result)
|
|
57
|
+
return raw;
|
|
58
|
+
// Extractor não rodou (regra sem needsData('go')): estado vazio, nunca crash.
|
|
59
|
+
return { result: { status: 'error', message: 'extractor go não executado' } };
|
|
60
|
+
}
|
|
61
|
+
function model(ctx) {
|
|
62
|
+
const r = goState(ctx).result;
|
|
63
|
+
return r.status === 'ok' ? r.model : null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* R1.4: `go` ausente no host NÃO é skip silencioso. As regras `error`
|
|
67
|
+
* retornam vazio (não podem avaliar), e esta regra dedicada emite um achado
|
|
68
|
+
* `warn` nomeado que o relatório destaca. Um gate que pula mudo reporta mais
|
|
69
|
+
* saúde do que existe; um gate que bloqueia por ferramenta ausente é gate
|
|
70
|
+
* que o time desliga. `--fail-on-warn` promove a bloqueio.
|
|
71
|
+
*/
|
|
72
|
+
export function goToolchainMissing(opts = {}) {
|
|
73
|
+
return rule('go-toolchain-missing')
|
|
74
|
+
.severity('warn')
|
|
75
|
+
.needsData('go')
|
|
76
|
+
.message("toolchain 'go' ausente ou extrator falhou: invariantes Go não avaliados. Instale Go >= 1.21 ou trate o skip — skip silencioso é o defeito que o gate existe para caçar")
|
|
77
|
+
.assert((ctx) => {
|
|
78
|
+
const r = goState(ctx).result;
|
|
79
|
+
// Só sinaliza quando HÁ arquivos .go (repo Go de verdade).
|
|
80
|
+
if (r.status === 'ok')
|
|
81
|
+
return [];
|
|
82
|
+
return [{ target: `go-extractor indisponível (${statusLabel(r)})` }];
|
|
83
|
+
})
|
|
84
|
+
.build();
|
|
85
|
+
}
|
|
86
|
+
function statusLabel(r) {
|
|
87
|
+
if (r.status === 'missing-go')
|
|
88
|
+
return "'go' ausente no PATH";
|
|
89
|
+
if (r.status === 'timeout')
|
|
90
|
+
return `go-parse-timeout após ${r.timeoutMs}ms`;
|
|
91
|
+
if (r.status === 'error')
|
|
92
|
+
return r.message;
|
|
93
|
+
return 'ok';
|
|
94
|
+
}
|
|
95
|
+
/** R1.3: arquivo com erro de parse vira achado `go-parse-error` (nunca mudo). */
|
|
96
|
+
export function goParseError() {
|
|
97
|
+
return rule('go-parse-error')
|
|
98
|
+
.severity('error')
|
|
99
|
+
.needsData('go')
|
|
100
|
+
.message('arquivo .go com erro de parse: ignorado pelas regras — corrija o arquivo ou exclua-o do escopo')
|
|
101
|
+
.assert((ctx) => {
|
|
102
|
+
const m = model(ctx);
|
|
103
|
+
if (!m)
|
|
104
|
+
return [];
|
|
105
|
+
return parseErrorTargets(m);
|
|
106
|
+
})
|
|
107
|
+
.build();
|
|
108
|
+
}
|
|
109
|
+
function parseErrorTargets(m) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const f of m.files) {
|
|
112
|
+
if (f.error === 'parse') {
|
|
113
|
+
out.push({ target: `${f.path} (erro de parse Go — arquivo ignorado pelas regras)`, location: f.path });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
function buildGuardIndex(files) {
|
|
119
|
+
const engines = new Set();
|
|
120
|
+
const groups = new Map();
|
|
121
|
+
const useByReceiver = new Map();
|
|
122
|
+
for (const f of files) {
|
|
123
|
+
for (const e of f.engineRoots)
|
|
124
|
+
engines.add(e.name);
|
|
125
|
+
for (const g of f.groupDefs) {
|
|
126
|
+
const prev = groups.get(g.var);
|
|
127
|
+
groups.set(g.var, {
|
|
128
|
+
parent: g.parent,
|
|
129
|
+
guards: [...(prev?.guards ?? []), ...g.guards],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
for (const u of f.useCalls) {
|
|
133
|
+
const list = useByReceiver.get(u.receiver) ?? [];
|
|
134
|
+
list.push(...u.guards);
|
|
135
|
+
useByReceiver.set(u.receiver, list);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { engines, groups, useByReceiver };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Guards herdados por um receiver: sobe a cadeia de grupos até a engine raiz
|
|
142
|
+
* (R3.4 — resolução transitiva de parent) e coleta os Use() de cada nível
|
|
143
|
+
* (R3.2 grupo, R3.3 engine).
|
|
144
|
+
*/
|
|
145
|
+
function inheritedGuards(receiver, idx) {
|
|
146
|
+
const out = [];
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
let cur = receiver;
|
|
149
|
+
while (cur && !seen.has(cur)) {
|
|
150
|
+
seen.add(cur);
|
|
151
|
+
out.push(...(idx.useByReceiver.get(cur) ?? []));
|
|
152
|
+
const g = idx.groups.get(cur);
|
|
153
|
+
if (g) {
|
|
154
|
+
out.push(...g.guards);
|
|
155
|
+
cur = g.parent;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
cur = undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function makeAuthMatcher(authSymbols, publicSymbols) {
|
|
164
|
+
const auth = new Set(authSymbols);
|
|
165
|
+
const isAuth = (sym) => {
|
|
166
|
+
// `gin.Auth` e `Auth` contam igual: compara o último segmento.
|
|
167
|
+
const leaf = sym.includes('.') ? sym.slice(sym.lastIndexOf('.') + 1) : sym;
|
|
168
|
+
return auth.has(leaf);
|
|
169
|
+
};
|
|
170
|
+
const isPublic = (sym) => {
|
|
171
|
+
const leaf = sym.includes('.') ? sym.slice(sym.lastIndexOf('.') + 1) : sym;
|
|
172
|
+
return publicSymbols.includes(leaf);
|
|
173
|
+
};
|
|
174
|
+
return { isAuth, isPublic };
|
|
175
|
+
}
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// R3.1–R3.6 — mutation-routes-guarded
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
/**
|
|
180
|
+
* Rota de mutação (POST/PUT/PATCH/DELETE) registrada em gin.Engine ou
|
|
181
|
+
* gin.RouterGroup sem middleware de auth em NENHUMA camada (inline,
|
|
182
|
+
* Group.Use, Engine.Use). A camada de grupo é a maior fonte de
|
|
183
|
+
* falso-negativo; a resolução transitiva (R3.2/R3.4) é obrigatória.
|
|
184
|
+
*
|
|
185
|
+
* Fronteira honesta: Recovery/Logger/CORS NÃO são auth (R3.5); rota de
|
|
186
|
+
* leitura (GET/HEAD/OPTIONS) nunca é alvo; `NoAuth()` explícito marca
|
|
187
|
+
* público intencional (R3.6).
|
|
188
|
+
*/
|
|
189
|
+
export function mutationRoutesGuarded(opts = {}) {
|
|
190
|
+
const { isAuth, isPublic } = makeAuthMatcher(opts.authSymbols ?? DEFAULT_AUTH_SYMBOLS, opts.publicSymbols ?? DEFAULT_PUBLIC_SYMBOLS);
|
|
191
|
+
return rule('mutation-routes-guarded')
|
|
192
|
+
.severity('error')
|
|
193
|
+
.needsData('go')
|
|
194
|
+
.message('rota de mutação Gin (POST/PUT/PATCH/DELETE) sem middleware de auth/autorização em nenhuma camada (inline, Group.Use, Engine.Use); Recovery/Logger/CORS não são auth')
|
|
195
|
+
.assert((ctx) => {
|
|
196
|
+
const m = model(ctx);
|
|
197
|
+
if (!m)
|
|
198
|
+
return []; // toolchain ausente: regra irmã `go-toolchain-missing` sinaliza (nunca mudo)
|
|
199
|
+
const out = [];
|
|
200
|
+
const idx = buildGuardIndex(m.files);
|
|
201
|
+
for (const f of m.files) {
|
|
202
|
+
if (f.error)
|
|
203
|
+
continue;
|
|
204
|
+
for (const r of f.routes) {
|
|
205
|
+
if (!MUTATING.has(r.method))
|
|
206
|
+
continue;
|
|
207
|
+
const inline = [...r.inlineGuards];
|
|
208
|
+
if (inline.some(isPublic))
|
|
209
|
+
continue; // público intencional (R3.6)
|
|
210
|
+
if (inline.some(isAuth))
|
|
211
|
+
continue; // protegida inline (R3.1)
|
|
212
|
+
const inherited = inheritedGuards(r.receiver, idx);
|
|
213
|
+
if (inherited.some(isAuth))
|
|
214
|
+
continue; // protegida em grupo/engine (R3.2/R3.3/R3.4)
|
|
215
|
+
out.push({
|
|
216
|
+
target: `${r.method} ${r.path} (${r.handler || 'handler'})`,
|
|
217
|
+
location: `${f.path}:${r.line}`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
})
|
|
223
|
+
.build();
|
|
224
|
+
}
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// R3.7/R3.8 — error-handling-enforced
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
/**
|
|
229
|
+
* O anti-padrão mais perigoso em Go: erro silenciosamente descartado.
|
|
230
|
+
*
|
|
231
|
+
* - R3.7 (error): descarte EXPLÍCITO — `_ = err`, `x, _ := f()`. Decisão
|
|
232
|
+
* deliberada de ignorar falha → bloqueia.
|
|
233
|
+
* - R3.8 (warn): chamada cujo último retorno é error aparece como ExprStmt
|
|
234
|
+
* puro (`db.Save(o)` sozinho). Sem type info o parser NÃO sabe quais
|
|
235
|
+
* funções retornam error, então é HEURÍSTICO: só `knownErrReturning` ou
|
|
236
|
+
* função que já aparece com `err` no mesmo arquivo. Por isso `warn`.
|
|
237
|
+
*
|
|
238
|
+
* Fronteira honesta: `if err := f(); err != nil` não é achado; `fmt.Println`
|
|
239
|
+
* (não retorna error) não é achado.
|
|
240
|
+
*/
|
|
241
|
+
export function errorHandlingEnforced(_opts = {}) {
|
|
242
|
+
return rule('error-handling-enforced')
|
|
243
|
+
.severity('error')
|
|
244
|
+
.needsData('go')
|
|
245
|
+
.message('erro de retorno descartado (_ = err, x, _ := f()) ou chamado sem if err != nil — o modo de falha silenciosa mais comum em Go')
|
|
246
|
+
.assert((ctx) => {
|
|
247
|
+
const m = model(ctx);
|
|
248
|
+
if (!m)
|
|
249
|
+
return []; // toolchain ausente: regra irmã `go-toolchain-missing` sinaliza (nunca mudo)
|
|
250
|
+
const out = [];
|
|
251
|
+
for (const f of m.files) {
|
|
252
|
+
if (f.error)
|
|
253
|
+
continue;
|
|
254
|
+
// R3.7 — descarte explícito: severity error (a regra é error).
|
|
255
|
+
for (const d of f.errorHandling.explicitDiscards) {
|
|
256
|
+
out.push({ target: `${d.expr} (erro descartado)`, location: `${f.path}:${d.line}` });
|
|
257
|
+
}
|
|
258
|
+
// R3.8 heurística vive na regra irmã `error-handling-unchecked`
|
|
259
|
+
// (warn): misturar heurística nesta regra `error` violaria a
|
|
260
|
+
// política do core ("check que falha por heurística é o que o time
|
|
261
|
+
// desliga na segunda semana").
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
})
|
|
265
|
+
.build();
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* R3.8 isolado — chamada que retorna error sem check, como `warn` (heurística
|
|
269
|
+
* sem type info). Separada de `error-handling-enforced` (que é `error`) para
|
|
270
|
+
* respeitar a política do core: heurística que bloqueia é heurística que o
|
|
271
|
+
* time desliga na segunda semana.
|
|
272
|
+
*
|
|
273
|
+
* Redução de falso-positivo: só `knownErrReturning` OU função que já aparece
|
|
274
|
+
* com `err` noutra linha do mesmo arquivo.
|
|
275
|
+
*/
|
|
276
|
+
export function errorHandlingUnchecked(opts = {}) {
|
|
277
|
+
const known = new Set(opts.knownErrReturning ?? DEFAULT_KNOWN_ERR_RETURNING);
|
|
278
|
+
return rule('error-handling-unchecked')
|
|
279
|
+
.severity('warn')
|
|
280
|
+
.needsData('go')
|
|
281
|
+
.message('chamada que retorna error usada como statement sem if err != nil (heurística: função em knownErrReturning ou vista com err no arquivo)')
|
|
282
|
+
.assert((ctx) => {
|
|
283
|
+
const m = model(ctx);
|
|
284
|
+
if (!m)
|
|
285
|
+
return []; // heurística: sem toolchain, sem achado (não é skip mudo — a regra error irmã já sinaliza)
|
|
286
|
+
const out = [];
|
|
287
|
+
for (const f of m.files) {
|
|
288
|
+
if (f.error)
|
|
289
|
+
continue;
|
|
290
|
+
// Funções que já aparecem com `err` neste arquivo (fator de
|
|
291
|
+
// redução de falso-positivo R3.8; a decisão de violar é daqui).
|
|
292
|
+
const checkedHere = new Set(f.checkedCallees.map(leafOf));
|
|
293
|
+
for (const c of f.errorHandling.uncheckedErrCalls) {
|
|
294
|
+
const fnName = calleeLeaf(c.expr);
|
|
295
|
+
if (!fnName)
|
|
296
|
+
continue;
|
|
297
|
+
const whitelisted = known.has(fnName);
|
|
298
|
+
const seenWithErr = checkedHere.has(fnName);
|
|
299
|
+
// R3.8: só heurística quando a função é conhecida por retornar
|
|
300
|
+
// error OU já aparece com err no mesmo arquivo.
|
|
301
|
+
if (!whitelisted && !seenWithErr)
|
|
302
|
+
continue;
|
|
303
|
+
out.push({ target: `${c.expr} (retorno de error ignorado)`, location: `${f.path}:${c.line}` });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
})
|
|
308
|
+
.build();
|
|
309
|
+
}
|
|
310
|
+
/** Extrai o leaf de um nome possivelmente qualificado: `db.Save` -> `Save`. */
|
|
311
|
+
function leafOf(name) {
|
|
312
|
+
return name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : name;
|
|
313
|
+
}
|
|
314
|
+
/** Extrai o nome do método/função chamado de uma expressão `a.B(...)` / `b(...)`. */
|
|
315
|
+
function calleeLeaf(expr) {
|
|
316
|
+
const m = expr.match(/^([\w.*]+)\(/);
|
|
317
|
+
if (!m)
|
|
318
|
+
return null;
|
|
319
|
+
const callee = m[1];
|
|
320
|
+
const leaf = callee.includes('.') ? callee.slice(callee.lastIndexOf('.') + 1) : callee;
|
|
321
|
+
return leaf;
|
|
322
|
+
}
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// R3.9–R3.12 — payload-validation-bound
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
/**
|
|
327
|
+
* Handler Gin que faz binding de payload para struct cujos campos não
|
|
328
|
+
* declaram tags `binding:"..."` de validação.
|
|
329
|
+
*
|
|
330
|
+
* R3.9: resolve o tipo da struct por heurística SINTÁTICA (var req T, req :=
|
|
331
|
+
* T{}, parâmetro func(req *T)). Se o tipo não for resolvível no arquivo,
|
|
332
|
+
* NÃO reporta (nunca adivinha).
|
|
333
|
+
* R3.10: qualquer campo de dados (exportado) com hasBinding==false é achado.
|
|
334
|
+
* R3.11: `binding:"-"` é ignorado (não faz parte do payload validável).
|
|
335
|
+
* R3.12: body (ShouldBindJSON/Bind/...) é `error`; query/uri é `warn`.
|
|
336
|
+
*/
|
|
337
|
+
export function payloadValidationBound() {
|
|
338
|
+
return rule('payload-validation-bound')
|
|
339
|
+
.severity('error')
|
|
340
|
+
.needsData('go')
|
|
341
|
+
.message('binding de payload (ShouldBindJSON/Bind) numa struct com campo de dados sem tag binding:"..." de validação')
|
|
342
|
+
.assert((ctx) => {
|
|
343
|
+
const m = model(ctx);
|
|
344
|
+
if (!m)
|
|
345
|
+
return []; // toolchain ausente: regra irmã `go-toolchain-missing` sinaliza (nunca mudo)
|
|
346
|
+
const out = [];
|
|
347
|
+
for (const f of m.files) {
|
|
348
|
+
if (f.error)
|
|
349
|
+
continue;
|
|
350
|
+
const structsByName = new Map(f.structs.map((s) => [s.name, s]));
|
|
351
|
+
const varType = new Map(f.varTypes.map((v) => [v.var, v.type]));
|
|
352
|
+
for (const b of f.bindings) {
|
|
353
|
+
if (!BODY_BINDINGS.has(b.func))
|
|
354
|
+
continue; // query/uri é warn (regra irmã)
|
|
355
|
+
const rawType = varType.get(b.varName);
|
|
356
|
+
if (!rawType)
|
|
357
|
+
continue; // R3.9: tipo não resolvível → não reporta
|
|
358
|
+
const typeName = rawType.replace(/^\*/, '').replace(/^[\w.]+\./, ''); // *db.Order -> Order? não: mantém leaf só p/ tipos locais
|
|
359
|
+
// só resolve structs LOCAIS (sem pacote): payload de entrada é local.
|
|
360
|
+
if (rawType.replace(/^\*/, '').includes('.'))
|
|
361
|
+
continue;
|
|
362
|
+
const st = structsByName.get(typeName);
|
|
363
|
+
if (!st)
|
|
364
|
+
continue;
|
|
365
|
+
const unvalidated = st.fields.filter((fl) => fl.bindingTag !== '-' && !fl.hasBinding && isExported(fl.name));
|
|
366
|
+
if (unvalidated.length === 0)
|
|
367
|
+
continue;
|
|
368
|
+
out.push({
|
|
369
|
+
target: `${typeName}.${unvalidated.map((fl) => fl.name).join(', ')} (sem binding)`,
|
|
370
|
+
location: `${f.path}:${b.line}`,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
})
|
|
376
|
+
.build();
|
|
377
|
+
}
|
|
378
|
+
/** R3.12 — binding de query/uri sem tag é `warn` (validação de query é frequentemente opcional). */
|
|
379
|
+
export function payloadValidationQuery(opts = {}) {
|
|
380
|
+
void opts;
|
|
381
|
+
return rule('payload-validation-query')
|
|
382
|
+
.severity('warn')
|
|
383
|
+
.needsData('go')
|
|
384
|
+
.message('binding de query/uri (ShouldBindQuery/ShouldBindUri) numa struct sem tag binding:"..." (validação de query é frequentemente opcional — warn)')
|
|
385
|
+
.assert((ctx) => {
|
|
386
|
+
const m = model(ctx);
|
|
387
|
+
if (!m)
|
|
388
|
+
return [];
|
|
389
|
+
const out = [];
|
|
390
|
+
for (const f of m.files) {
|
|
391
|
+
if (f.error)
|
|
392
|
+
continue;
|
|
393
|
+
const structsByName = new Map(f.structs.map((s) => [s.name, s]));
|
|
394
|
+
const varType = new Map(f.varTypes.map((v) => [v.var, v.type]));
|
|
395
|
+
for (const b of f.bindings) {
|
|
396
|
+
if (!QUERY_BINDINGS.has(b.func))
|
|
397
|
+
continue;
|
|
398
|
+
const rawType = varType.get(b.varName);
|
|
399
|
+
if (!rawType || rawType.replace(/^\*/, '').includes('.'))
|
|
400
|
+
continue;
|
|
401
|
+
const typeName = rawType.replace(/^\*/, '');
|
|
402
|
+
const st = structsByName.get(typeName);
|
|
403
|
+
if (!st)
|
|
404
|
+
continue;
|
|
405
|
+
const unvalidated = st.fields.filter((fl) => fl.bindingTag !== '-' && !fl.hasBinding && isExported(fl.name));
|
|
406
|
+
if (unvalidated.length === 0)
|
|
407
|
+
continue;
|
|
408
|
+
out.push({
|
|
409
|
+
target: `${typeName}.${unvalidated.map((fl) => fl.name).join(', ')} (query sem binding)`,
|
|
410
|
+
location: `${f.path}:${b.line}`,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
})
|
|
416
|
+
.build();
|
|
417
|
+
}
|
|
418
|
+
function isExported(name) {
|
|
419
|
+
return /^[A-Z]/.test(name);
|
|
420
|
+
}
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// R2.x — isolamento de oráculo em Go (regras nativas; o check de core só
|
|
423
|
+
// entende import de TypeScript, então Go precisa de arestas próprias).
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
function srcPrefixes(opts) {
|
|
426
|
+
return opts.srcDirs ?? ['internal', 'pkg', 'cmd'];
|
|
427
|
+
}
|
|
428
|
+
function oraclePrefixes(opts) {
|
|
429
|
+
return opts.oracleDirs ?? [...DEFAULT_ORACLE_DIRS];
|
|
430
|
+
}
|
|
431
|
+
/** Import interno do alvo resolvido contra o module path do go.mod. */
|
|
432
|
+
function isInternalImport(importPath, module) {
|
|
433
|
+
if (!module)
|
|
434
|
+
return false;
|
|
435
|
+
const rel = importPath.startsWith(module + '/')
|
|
436
|
+
? importPath.slice(module.length + 1)
|
|
437
|
+
: null;
|
|
438
|
+
if (rel === null)
|
|
439
|
+
return false; // import externo (outro módulo)
|
|
440
|
+
return rel.startsWith('internal/') || rel.includes('/internal/');
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* R2.1/R2.2/R2.3/R2.4 — teste de oráculo que importa pacote interno da
|
|
444
|
+
* aplicação (resolvido contra o module path) é espelho. Cobre import
|
|
445
|
+
* absoluto, alias, dot e blank: a burla por alias está bloqueada porque a
|
|
446
|
+
* resolução é por path, não por nome.
|
|
447
|
+
*/
|
|
448
|
+
export function oracleNoSrc(opts = {}) {
|
|
449
|
+
return rule('oracle-no-src')
|
|
450
|
+
.severity('error')
|
|
451
|
+
.needsData('go')
|
|
452
|
+
.message('teste de oráculo importa pacote interno da aplicação (resolvido contra o module do go.mod): é espelho, não oráculo')
|
|
453
|
+
.assert((ctx) => {
|
|
454
|
+
const m = model(ctx);
|
|
455
|
+
if (!m)
|
|
456
|
+
return [];
|
|
457
|
+
const out = [];
|
|
458
|
+
const oracleDirs = oraclePrefixes(opts);
|
|
459
|
+
for (const f of m.files) {
|
|
460
|
+
if (f.error)
|
|
461
|
+
continue;
|
|
462
|
+
if (!isUnderAny(f.path, oracleDirs))
|
|
463
|
+
continue;
|
|
464
|
+
for (const imp of f.imports) {
|
|
465
|
+
if (isInternalImport(imp.path, m.module)) {
|
|
466
|
+
out.push({
|
|
467
|
+
target: `${f.path} -> ${imp.path}`,
|
|
468
|
+
location: `${f.path}:${imp.line}`,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
474
|
+
})
|
|
475
|
+
.build();
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* R2.5 — regra nativa de Go: um `_test.go` DENTRO da árvore internal/ (ou
|
|
479
|
+
* layout.src) que declara `package foo` (e não `package foo_test`) acessa
|
|
480
|
+
* símbolos não-exportados da implementação: é espelho branco por construção,
|
|
481
|
+
* mesmo sem import. `package foo_test` é black-box e NÃO viola.
|
|
482
|
+
*/
|
|
483
|
+
export function oracleWhiteboxTest(opts = {}) {
|
|
484
|
+
return rule('oracle-whitebox-test')
|
|
485
|
+
.severity('error')
|
|
486
|
+
.needsData('go')
|
|
487
|
+
.message("_test.go dentro de internal/ que declara `package foo` (não `package foo_test`) acessa símbolos não-exportados: espelho branco por construção")
|
|
488
|
+
.assert((ctx) => {
|
|
489
|
+
const m = model(ctx);
|
|
490
|
+
if (!m)
|
|
491
|
+
return [];
|
|
492
|
+
const out = [];
|
|
493
|
+
for (const f of m.files) {
|
|
494
|
+
if (f.error || !f.isTest)
|
|
495
|
+
continue;
|
|
496
|
+
const inSrcTree = f.isInternal || isUnderAny(f.path, srcPrefixes(opts));
|
|
497
|
+
if (!inSrcTree)
|
|
498
|
+
continue;
|
|
499
|
+
// package foo_test é black-box; package foo é white-box.
|
|
500
|
+
if (f.package.endsWith('_test'))
|
|
501
|
+
continue;
|
|
502
|
+
out.push({
|
|
503
|
+
target: `${f.path} (package ${f.package} white-box em árvore de implementação)`,
|
|
504
|
+
location: f.path,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return out;
|
|
508
|
+
})
|
|
509
|
+
.build();
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* R2.7 — fronteira reversa: produção (internal/ ou layout.src) importando o
|
|
513
|
+
* diretório de oráculo é espelho invertido (fixture vazando para produção).
|
|
514
|
+
*/
|
|
515
|
+
export function srcNoOracle(opts = {}) {
|
|
516
|
+
return rule('src-no-oracle')
|
|
517
|
+
.severity('error')
|
|
518
|
+
.needsData('go')
|
|
519
|
+
.message('implementação importa diretório de oráculo: fixture de teste vazando para produção')
|
|
520
|
+
.assert((ctx) => {
|
|
521
|
+
const m = model(ctx);
|
|
522
|
+
if (!m)
|
|
523
|
+
return [];
|
|
524
|
+
const out = [];
|
|
525
|
+
const src = srcPrefixes(opts);
|
|
526
|
+
const oracleDirs = oraclePrefixes(opts);
|
|
527
|
+
for (const f of m.files) {
|
|
528
|
+
if (f.error)
|
|
529
|
+
continue;
|
|
530
|
+
const inSrc = f.isInternal || isUnderAny(f.path, src);
|
|
531
|
+
if (!inSrc)
|
|
532
|
+
continue;
|
|
533
|
+
for (const imp of f.imports) {
|
|
534
|
+
const rel = m.module && imp.path.startsWith(m.module + '/')
|
|
535
|
+
? imp.path.slice(m.module.length + 1)
|
|
536
|
+
: imp.path;
|
|
537
|
+
if (isUnderAny(rel, oracleDirs)) {
|
|
538
|
+
out.push({ target: `${f.path} -> ${imp.path}`, location: `${f.path}:${imp.line}` });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return out;
|
|
543
|
+
})
|
|
544
|
+
.build();
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* R2.8 — em topologia de módulo separado, `oracle/go.mod` não pode
|
|
548
|
+
* require/replace o módulo da aplicação: se contém, a fronteira do
|
|
549
|
+
* compilador foi desativada de propósito.
|
|
550
|
+
*
|
|
551
|
+
* Lido direto do filesystem (o extrator Go não parseia go.mod de submódulo):
|
|
552
|
+
* é um tripwire textual, não AST — suficiente para o `require`/`replace`.
|
|
553
|
+
*/
|
|
554
|
+
export function oracleModuleCouplesApp() {
|
|
555
|
+
return rule('oracle-module-couples-app')
|
|
556
|
+
.severity('error')
|
|
557
|
+
.needsData('go')
|
|
558
|
+
.message('oracle/go.mod contém require/replace do módulo da aplicação: a fronteira imposta pelo compilador foi desativada de propósito')
|
|
559
|
+
.assert(async (ctx) => {
|
|
560
|
+
const m = model(ctx);
|
|
561
|
+
if (!m || !m.module)
|
|
562
|
+
return [];
|
|
563
|
+
const { readFile } = await import('node:fs/promises');
|
|
564
|
+
const { resolve } = await import('node:path');
|
|
565
|
+
const out = [];
|
|
566
|
+
// Procura oracle/go.mod (e variantes) sob os diretórios de oráculo.
|
|
567
|
+
const candidates = await findOracleGoMods(ctx);
|
|
568
|
+
for (const rel of candidates) {
|
|
569
|
+
let text = '';
|
|
570
|
+
try {
|
|
571
|
+
text = await readFile(resolve(ctx.root, rel), 'utf8');
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const mod = m.module;
|
|
577
|
+
const re = new RegExp(`^\\s*(require|replace)\\s+${escapeRe(mod)}(\\s|/|$)`, 'm');
|
|
578
|
+
if (re.test(text)) {
|
|
579
|
+
out.push({ target: `${rel} (${mod} requerido/trocado pelo oráculo)`, location: rel });
|
|
580
|
+
break; // um achado por go.mod: o alvo é o arquivo, não a linha
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return out;
|
|
584
|
+
})
|
|
585
|
+
.build();
|
|
586
|
+
}
|
|
587
|
+
async function findOracleGoMods(ctx) {
|
|
588
|
+
// Heurística: qualquer go.mod cujo caminho contenha 'oracle'.
|
|
589
|
+
const all = await walkGoMod(ctx.root);
|
|
590
|
+
return all.filter((rel) => /(^|\/)oracle(\/|$)/.test(rel));
|
|
591
|
+
}
|
|
592
|
+
async function walkGoMod(root) {
|
|
593
|
+
const { walk } = await import('@ihelpee/prumo/core');
|
|
594
|
+
return walk(root, '.', {
|
|
595
|
+
extensions: ['.mod'],
|
|
596
|
+
ignoreDirs: new Set(['node_modules', '.git', 'vendor']),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
function escapeRe(s) {
|
|
600
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
601
|
+
}
|
|
602
|
+
// ---------------------------------------------------------------------------
|
|
603
|
+
// R3.13/R3.14 — invariantes Go adicionais (alto valor, baixo custo)
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
/**
|
|
606
|
+
* R3.13 — `internal/<featureA>/` importando `internal/<featureB>/`
|
|
607
|
+
* (fronteira de contexto). Heurística `featureOf` adaptada ao layout Go.
|
|
608
|
+
* `warn` no MVP (layouts Go variam).
|
|
609
|
+
*/
|
|
610
|
+
export function noCrossFeatureImport(opts = {}) {
|
|
611
|
+
return rule('no-cross-feature-import')
|
|
612
|
+
.severity('warn')
|
|
613
|
+
.needsData('go')
|
|
614
|
+
.message('feature interna importa outra feature (internal/<A> -> internal/<B>): fronteira de contexto violada')
|
|
615
|
+
.assert((ctx) => {
|
|
616
|
+
const m = model(ctx);
|
|
617
|
+
if (!m || !m.module)
|
|
618
|
+
return [];
|
|
619
|
+
const out = [];
|
|
620
|
+
const featureOf = (rel) => {
|
|
621
|
+
const mm = rel.match(/^internal\/([^/]+)\//);
|
|
622
|
+
return mm ? mm[1] : null;
|
|
623
|
+
};
|
|
624
|
+
for (const f of m.files) {
|
|
625
|
+
if (f.error)
|
|
626
|
+
continue;
|
|
627
|
+
const from = featureOf(f.path);
|
|
628
|
+
if (!from)
|
|
629
|
+
continue;
|
|
630
|
+
for (const imp of f.imports) {
|
|
631
|
+
if (!imp.path.startsWith(m.module + '/'))
|
|
632
|
+
continue;
|
|
633
|
+
const rel = imp.path.slice(m.module.length + 1);
|
|
634
|
+
const to = featureOf(rel);
|
|
635
|
+
if (to && to !== from) {
|
|
636
|
+
out.push({ target: `${f.path} -> ${imp.path} (${from} importa ${to})`, location: `${f.path}:${imp.line}` });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return out;
|
|
641
|
+
})
|
|
642
|
+
.build();
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* R3.14 — campo monetário tipado como float32/float64. Em Go o anti-padrão
|
|
646
|
+
* é float64 para dinheiro; int64 (centavos) ou shopspring/decimal é aceitável.
|
|
647
|
+
* `warn`.
|
|
648
|
+
*/
|
|
649
|
+
export function monetaryIsInteger() {
|
|
650
|
+
return rule('monetary-is-integer')
|
|
651
|
+
.severity('warn')
|
|
652
|
+
.needsData('go')
|
|
653
|
+
.message('campo monetário tipado como float32/float64; use int64 (centavos) ou decimal')
|
|
654
|
+
.assert((ctx) => {
|
|
655
|
+
const m = model(ctx);
|
|
656
|
+
if (!m)
|
|
657
|
+
return [];
|
|
658
|
+
const out = [];
|
|
659
|
+
for (const f of m.files) {
|
|
660
|
+
if (f.error)
|
|
661
|
+
continue;
|
|
662
|
+
for (const s of f.structs) {
|
|
663
|
+
for (const fl of s.fields) {
|
|
664
|
+
if (hasCentsSuffix(fl.name))
|
|
665
|
+
continue;
|
|
666
|
+
if (!MONETARY_NAME.test(fl.name))
|
|
667
|
+
continue;
|
|
668
|
+
if (!FLOAT_TYPE.test(fl.type))
|
|
669
|
+
continue;
|
|
670
|
+
out.push({ target: `${s.name}.${fl.name} ${fl.type}`, location: `${f.path}:${s.line}` });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return out;
|
|
675
|
+
})
|
|
676
|
+
.build();
|
|
677
|
+
}
|
|
678
|
+
function isUnderAny(p, prefixes) {
|
|
679
|
+
return startsWithAny(p, prefixes);
|
|
680
|
+
}
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// O plugin
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
/**
|
|
685
|
+
* O Plugin oficial de Go/Gin para o Prumo (spec §4.5).
|
|
686
|
+
*
|
|
687
|
+
* Analisa, nunca executa: o extrator Go é stdlib-only e só faz
|
|
688
|
+
* `parser.ParseFile` — o app analisado nunca dá boot, o parser nunca herda o
|
|
689
|
+
* drift do que analisa (R1.2). A lei vive 100% neste arquivo Node (§4.2).
|
|
690
|
+
*/
|
|
691
|
+
export function goGinPlugin(opts = {}) {
|
|
692
|
+
return definePlugin({
|
|
693
|
+
name: 'go-gin',
|
|
694
|
+
version: pkg.version,
|
|
695
|
+
description: 'Go/Gin invariants: mutation route guards (inline/Group.Use/Engine.Use transitivo), error-handling enforcement, payload binding validation, oracle isolation nativa de Go (internal/, white-box, módulo separado).',
|
|
696
|
+
extractors: {
|
|
697
|
+
go: async (ctx) => ({ result: await runGoExtractor(ctx.root, opts) }),
|
|
698
|
+
},
|
|
699
|
+
rules: [
|
|
700
|
+
goToolchainMissing(opts),
|
|
701
|
+
goParseError(),
|
|
702
|
+
mutationRoutesGuarded(opts),
|
|
703
|
+
errorHandlingEnforced(opts),
|
|
704
|
+
errorHandlingUnchecked(opts),
|
|
705
|
+
payloadValidationBound(),
|
|
706
|
+
payloadValidationQuery(opts),
|
|
707
|
+
oracleNoSrc(opts),
|
|
708
|
+
oracleWhiteboxTest(opts),
|
|
709
|
+
srcNoOracle(opts),
|
|
710
|
+
oracleModuleCouplesApp(),
|
|
711
|
+
noCrossFeatureImport(opts),
|
|
712
|
+
monetaryIsInteger(),
|
|
713
|
+
],
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
/** Preset pronto para `invariants: [goGinPreset]` / `.prumo/invariants/rules.ts`. */
|
|
717
|
+
export const goGinPreset = goGinPlugin().rules;
|
|
718
|
+
export default goGinPlugin;
|