@mocoto/mahoraga 0.14.5 → 0.14.7
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/README.md +176 -544
- package/dist/analysts/css/analysts/analyst-css.js +10 -1
- package/dist/analysts/detectors/detector-bugs-ml.js +81 -19
- package/dist/analysts/detectors/detector-structure.js +1 -1
- package/dist/analysts/react/analysts/analyst-react-hooks.js +173 -57
- package/dist/analysts/react/analysts/analyst-react.js +12 -0
- package/dist/analysts/react/detectors/detector-react-best-practices.js +121 -4
- package/dist/analysts/tailwind/analysts/analyst-tailwind.js +5 -2
- package/dist/cli/commands/command-perf.js +1 -1
- package/dist/cli/commands/command-update.js +3 -2
- package/dist/core/messages/en/core/formatters-messages.js +1 -0
- package/dist/core/messages/ja/core/formatters-messages.js +1 -0
- package/dist/core/messages/pt/core/formatters-messages.js +1 -0
- package/dist/core/messages/zh/core/formatters-messages.js +1 -0
- package/dist/core/parsing/langs/typescript.js +27 -7
- package/dist/core/utils/exec-safe.js +56 -16
- package/dist/node.loader.js +0 -1
- package/package.json +10 -17
- package/dist/index.js +0 -2
- package/dist/sdk/analyzer.js +0 -88
- package/dist/sdk/index.js +0 -2
- package/dist/sdk/sdk-legacy.js +0 -36
- package/dist/types/sdk/analyzer.js +0 -1
- package/dist/types/sdk/index.js +0 -1
|
@@ -179,7 +179,16 @@ const CONHECIDAS_CSS_PROPRIEDADES = new Set([
|
|
|
179
179
|
// CSS3 e modernas
|
|
180
180
|
'transform-origin', 'inset', 'background-clip', 'max-width', 'max-height', 'min-height', 'animation-delay', 'pointer-events', 'scroll-behavior', 'background-attachment', 'text-shadow', 'inset-inline', 'inset-block', 'inset-inline-start', 'inset-inline-end', 'inset-block-start', 'inset-block-end',
|
|
181
181
|
// Outros comuns
|
|
182
|
-
'cursor', 'opacity', 'transform', 'transition', 'animation', 'list-style', 'vertical-align', 'text-overflow', 'box-shadow', 'border-radius', 'outline', 'content', 'counter-reset', 'counter-increment'
|
|
182
|
+
'cursor', 'opacity', 'transform', 'transition', 'animation', 'list-style', 'vertical-align', 'text-overflow', 'box-shadow', 'border-radius', 'outline', 'content', 'counter-reset', 'counter-increment',
|
|
183
|
+
// CSS modernas (propriedades válidas e amplamente suportadas — ver feedback.md 1.4)
|
|
184
|
+
'clip-path', 'backdrop-filter', 'aspect-ratio', 'accent-color', 'appearance', 'user-select',
|
|
185
|
+
'object-fit', 'object-position', 'mix-blend-mode', 'filter', 'backdrop', 'will-change',
|
|
186
|
+
'place-items', 'place-content', 'place-self', 'grid-auto-flow', 'grid-auto-columns', 'grid-auto-rows',
|
|
187
|
+
'scroll-snap-type', 'scroll-snap-align', 'scroll-snap-stop', 'scroll-behavior', 'scroll-padding',
|
|
188
|
+
'text-wrap', 'overflow-wrap', 'word-break', 'hyphens', 'line-break', 'paint-order',
|
|
189
|
+
'touch-action', 'user-zoom', 'zoom', 'field-sizing', 'd', 'fill', 'stroke', 'stroke-width',
|
|
190
|
+
'mask-image', 'mask-size', 'mask-position', 'mask-repeat', 'clip-rule', 'fill-rule',
|
|
191
|
+
'isolation', 'contain', 'contain-intrinsic-size', 'content-visibility', 'rotate', 'scale', 'translate'
|
|
183
192
|
]);
|
|
184
193
|
function isValidCssProperty(prop) {
|
|
185
194
|
const normalized = prop.toLowerCase();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { parse as babelParse } from '@babel/parser';
|
|
2
|
+
import traverse from '@babel/traverse';
|
|
3
|
+
import * as t from '@babel/types';
|
|
4
4
|
import { getMessages } from '../../core/messages/index.js';
|
|
5
5
|
const MAX_MESSAGE_PREVIEW_LENGTH = 60;
|
|
6
6
|
// Statistical bug patterns with ML-like scoring
|
|
@@ -180,27 +180,42 @@ const BUG_PATTERNS = [
|
|
|
180
180
|
severity: 'aviso',
|
|
181
181
|
category: 'logic',
|
|
182
182
|
weight: 0.7,
|
|
183
|
-
detect(content,
|
|
183
|
+
detect(content, relPath) {
|
|
184
|
+
// Contagem ingênua de todos os `()[]{}` globais gera falso positivo em
|
|
185
|
+
// bases modernas: aninhamento de JSX (divs aninhadas) e parênteses/colchetes
|
|
186
|
+
// de expressões inflam a profundidade sem aumentar a complexidade de
|
|
187
|
+
// decisão. Seguindo feedback.md seção 1.5, contamos apenas o aninhamento
|
|
188
|
+
// de BLOCOS DE LÓGICA (BlockStatement) via AST — JSX, literais de objeto/
|
|
189
|
+
// array e parênteses de expressão são ignorados. Arquivos não analisáveis
|
|
190
|
+
// mantêm o fallback conservador por contagem de colchetes.
|
|
191
|
+
const logic = computeLogicNestingDepth(content, relPath);
|
|
184
192
|
let maxDepth = 0;
|
|
185
|
-
let currentDepth = 0;
|
|
186
193
|
let depthLine = 0;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
if (logic !== null) {
|
|
195
|
+
maxDepth = logic.depth;
|
|
196
|
+
depthLine = logic.line;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
let currentDepth = 0;
|
|
200
|
+
let lineCount = 1;
|
|
201
|
+
for (const char of content) {
|
|
202
|
+
if (char === '\n') {
|
|
203
|
+
lineCount++;
|
|
204
|
+
}
|
|
205
|
+
if (char === '{' || char === '(' || char === '[') {
|
|
206
|
+
currentDepth++;
|
|
207
|
+
if (currentDepth > maxDepth) {
|
|
208
|
+
maxDepth = currentDepth;
|
|
209
|
+
depthLine = lineCount;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else if (char === '}' || char === ')' || char === ']') {
|
|
213
|
+
currentDepth = Math.max(0, currentDepth - 1);
|
|
197
214
|
}
|
|
198
|
-
}
|
|
199
|
-
else if (char === '}' || char === ')' || char === ']') {
|
|
200
|
-
currentDepth = Math.max(0, currentDepth - 1);
|
|
201
215
|
}
|
|
202
216
|
}
|
|
203
|
-
|
|
217
|
+
// Limiar maior: só sinaliza aninhamento de lógica genuinamente profundo.
|
|
218
|
+
if (maxDepth > 7) {
|
|
204
219
|
return {
|
|
205
220
|
confidence: Math.min(0.95, 0.5 + maxDepth * 0.06),
|
|
206
221
|
line: depthLine,
|
|
@@ -447,6 +462,53 @@ function hasValidation(content, _line) {
|
|
|
447
462
|
function contarLinhasAtePos(content, pos) {
|
|
448
463
|
return content.slice(0, pos).split('\n').length;
|
|
449
464
|
}
|
|
465
|
+
const traverseFn = traverse;
|
|
466
|
+
/**
|
|
467
|
+
* Calcula a profundidade de aninhamento de BLOCOS DE LÓGICA (BlockStatement)
|
|
468
|
+
* via AST, ignorando JSX, literais de objeto/array e parênteses de expressões
|
|
469
|
+
* — que a contagem ingênua de colchetes contava indevidamente (feedback.md 1.5).
|
|
470
|
+
* Retorna `null` quando o arquivo não é JS/TS ou não pôde ser parseado.
|
|
471
|
+
*/
|
|
472
|
+
function computeLogicNestingDepth(content, relPath) {
|
|
473
|
+
if (!/\.(tsx?|jsx?|mjs|cjs)$/i.test(relPath)) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
const lower = relPath.toLowerCase();
|
|
478
|
+
const isTs = lower.endsWith('.ts') || lower.endsWith('.tsx');
|
|
479
|
+
const isJsxLike = lower.endsWith('.tsx') || lower.endsWith('.jsx');
|
|
480
|
+
const plugins = ['decorators-legacy', 'classProperties', 'classPrivateProperties', 'classPrivateMethods', 'optionalChaining', 'nullishCoalescingOperator', 'topLevelAwait', 'importAttributes'];
|
|
481
|
+
if (isTs) {
|
|
482
|
+
plugins.unshift('typescript');
|
|
483
|
+
}
|
|
484
|
+
if (isJsxLike) {
|
|
485
|
+
plugins.unshift('jsx');
|
|
486
|
+
}
|
|
487
|
+
const ast = babelParse(content, { sourceType: 'unambiguous', errorRecovery: true, plugins: plugins });
|
|
488
|
+
let depth = 0;
|
|
489
|
+
let max = 0;
|
|
490
|
+
let maxLine = 0;
|
|
491
|
+
const visitor = {
|
|
492
|
+
BlockStatement: {
|
|
493
|
+
enter(path) {
|
|
494
|
+
depth++;
|
|
495
|
+
if (depth > max) {
|
|
496
|
+
max = depth;
|
|
497
|
+
maxLine = path.node.loc?.start.line ?? 0;
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
exit() {
|
|
501
|
+
depth--;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
traverseFn(ast, visitor);
|
|
506
|
+
return { depth: max, line: maxLine };
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
450
512
|
function detectarPadroesBug(src, relPath, ocorrencias) {
|
|
451
513
|
for (const pattern of BUG_PATTERNS) {
|
|
452
514
|
try {
|
|
@@ -51,7 +51,7 @@ export const detectorEstrutura = {
|
|
|
51
51
|
const setCaminhos = new Set(caminhos);
|
|
52
52
|
// Detecta contexto geral do projeto para análise inteligente
|
|
53
53
|
const arquivoPrincipal = caminhos?.find(caminho => /package\.json$/.test(caminho)) ?? caminhos[0];
|
|
54
|
-
const conteudoPrincipal = contexto.arquivos
|
|
54
|
+
const conteudoPrincipal = contexto.arquivos?.find(arquivo => arquivo.relPath === arquivoPrincipal)?.content ?? '';
|
|
55
55
|
const contextoProjeto = detectarContextoProjeto({
|
|
56
56
|
arquivo: arquivoPrincipal,
|
|
57
57
|
conteudo: conteudoPrincipal,
|
|
@@ -33,8 +33,63 @@ function extractHookCallContent(src, startIndex) {
|
|
|
33
33
|
let depth = 0;
|
|
34
34
|
let started = false;
|
|
35
35
|
let content = '';
|
|
36
|
+
let inSingle = false;
|
|
37
|
+
let inDouble = false;
|
|
38
|
+
let inBacktick = false;
|
|
39
|
+
let escaped = false;
|
|
36
40
|
for (let i = startIndex; i < src.length; i++) {
|
|
37
41
|
const char = src[i];
|
|
42
|
+
if (escaped) {
|
|
43
|
+
escaped = false;
|
|
44
|
+
if (started)
|
|
45
|
+
content += char;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (char === '\\' && (inSingle || inDouble || inBacktick)) {
|
|
49
|
+
escaped = true;
|
|
50
|
+
if (started)
|
|
51
|
+
content += char;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (inSingle) {
|
|
55
|
+
if (char === "'")
|
|
56
|
+
inSingle = false;
|
|
57
|
+
if (started)
|
|
58
|
+
content += char;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (inDouble) {
|
|
62
|
+
if (char === '"')
|
|
63
|
+
inDouble = false;
|
|
64
|
+
if (started)
|
|
65
|
+
content += char;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (inBacktick) {
|
|
69
|
+
if (char === '`')
|
|
70
|
+
inBacktick = false;
|
|
71
|
+
if (started)
|
|
72
|
+
content += char;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (char === "'") {
|
|
76
|
+
inSingle = true;
|
|
77
|
+
if (started)
|
|
78
|
+
content += char;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (char === '"') {
|
|
82
|
+
inDouble = true;
|
|
83
|
+
if (started)
|
|
84
|
+
content += char;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (char === '`') {
|
|
88
|
+
inBacktick = true;
|
|
89
|
+
if (started)
|
|
90
|
+
content += char;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
38
93
|
if (char === '(') {
|
|
39
94
|
depth++;
|
|
40
95
|
started = true;
|
|
@@ -51,50 +106,80 @@ function extractHookCallContent(src, startIndex) {
|
|
|
51
106
|
}
|
|
52
107
|
return content;
|
|
53
108
|
}
|
|
54
|
-
function collectHookIssues(src, relPath) {
|
|
109
|
+
function collectHookIssues(src, relPath, opts = {}) {
|
|
55
110
|
const ocorrencias = [];
|
|
56
111
|
const lineOf = createLineLookup(src).lineAt;
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
112
|
+
// As verificações estruturais (useEffect/useMemo/useCallback sem deps e hook
|
|
113
|
+
// em condicional) são imprecisas quando feitas por regex/linha e geram falsos
|
|
114
|
+
// positivos. Quando a análise por AST (`parseHooksWithBabel`) está disponível,
|
|
115
|
+
// ela é a fonte autoritativa e estas verificações devem ser ignoradas.
|
|
116
|
+
const skipStructural = opts.skipStructural === true;
|
|
117
|
+
if (!skipStructural) {
|
|
118
|
+
// Check useEffect/useLayoutEffect for deps
|
|
119
|
+
const effectMatches = [...src.matchAll(/use(Layout)?Effect\s*\(/g)];
|
|
120
|
+
effectMatches.forEach(m => {
|
|
121
|
+
const hookInicio = m.index;
|
|
122
|
+
const fullCall = extractHookCallContent(src, hookInicio + m[0].length - 1);
|
|
123
|
+
// More robust deps detection: find the last comma followed by an array or identifier before the closing paren
|
|
124
|
+
const hasDepsArg = (() => {
|
|
125
|
+
// Find the last ')' (closing paren of the full call)
|
|
126
|
+
const lastClosing = fullCall.lastIndexOf(')');
|
|
127
|
+
if (lastClosing === -1)
|
|
128
|
+
return false;
|
|
129
|
+
const beforeClosing = fullCall.slice(0, lastClosing);
|
|
130
|
+
// Find the last '[' or identifier before the closing paren
|
|
131
|
+
const arrayMatch = beforeClosing.match(/,\s*(\[[\s\S]*?\])\s*$/);
|
|
132
|
+
if (arrayMatch)
|
|
133
|
+
return true;
|
|
134
|
+
const identMatch = beforeClosing.match(/,\s*(\w+)\s*$/);
|
|
135
|
+
if (identMatch)
|
|
136
|
+
return true;
|
|
137
|
+
return false;
|
|
138
|
+
})();
|
|
139
|
+
const skip = /no-deps-ok|eslint-disable-next-line\s+react-hooks\/exhaustive-deps/i.test(fullCall);
|
|
140
|
+
if (!hasDepsArg && !skip) {
|
|
141
|
+
const line = lineOf(hookInicio);
|
|
142
|
+
ocorrencias.push(warn(messages.ReactHooksMensagens.useEffectNoDeps, relPath, line));
|
|
143
|
+
}
|
|
144
|
+
if (hasDepsArg && /\[\s*\]/.test(fullCall)) {
|
|
145
|
+
const bodyWithRefs = fullCall.match(/^[\s\S]*?(?=,\s*\[)/);
|
|
146
|
+
if (bodyWithRefs) {
|
|
147
|
+
const varRefs = bodyWithRefs[0].match(/\b([a-z]\w+)\b/g) ?? [];
|
|
148
|
+
const nonTrivialRefs = varRefs.filter(v => !/^(set|use)/.test(v) && v.length > 1 && !['undefined', 'null', 'true', 'false'].includes(v));
|
|
149
|
+
if (nonTrivialRefs.length > 0) {
|
|
150
|
+
const line = lineOf(hookInicio);
|
|
151
|
+
ocorrencias.push(warn('Empty deps array but effect references component variables', relPath, line));
|
|
152
|
+
}
|
|
76
153
|
}
|
|
77
154
|
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
155
|
+
});
|
|
156
|
+
// useMemo/useCallback deps check
|
|
157
|
+
const memoMatches = [...src.matchAll(/use(Memo|Callback)\s*\(/g)];
|
|
158
|
+
memoMatches.forEach(m => {
|
|
159
|
+
const hookInicio = m.index;
|
|
160
|
+
const fullCall = extractHookCallContent(src, hookInicio + m[0].length - 1);
|
|
161
|
+
const hasDepsArg = /,\s*(\[[\s\S]*?\]|\w+)\s*\)$/.test(fullCall);
|
|
162
|
+
const skip = /no-deps-ok|eslint-disable-next-line\s+react-hooks\/exhaustive-deps/i.test(fullCall);
|
|
163
|
+
if (!hasDepsArg && !skip) {
|
|
164
|
+
const line = lineOf(hookInicio);
|
|
165
|
+
ocorrencias.push(warn(messages.ReactHooksMensagens.memoCallbackNoDeps, relPath, line));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
// Conditional hooks — only flag when a hook call is directly inside a conditional block,
|
|
169
|
+
// not when the conditional is inside a hook callback (e.g. cleanup inside useEffect).
|
|
170
|
+
// The regex matches: if/for/while (...) { ... useXxx ... } but limits the match scope
|
|
171
|
+
// to avoid matching conditionals that appear inside hook callbacks.
|
|
172
|
+
const conditionalHooks = [...src.matchAll(/(?:^|(?<=[\n;]))\s*(if|for|while)\s*\([^)]*\)\s*\{[\s\S]{0,160}?use[A-Z][A-Za-z0-9_]*/g)];
|
|
173
|
+
conditionalHooks.forEach(m => {
|
|
174
|
+
const line = lineOf(m.index || 0);
|
|
175
|
+
// Heuristic: skip if the 'if' keyword is preceded by a closing paren or brace at the same indentation,
|
|
176
|
+
// which suggests it's inside a callback body rather than at the component top level.
|
|
177
|
+
const pre = src.slice(Math.max(0, (m.index ?? 0) - 80), m.index ?? 0);
|
|
178
|
+
if (/=>\s*$|\)\s*=>\s*$|\)\s*\{[\s\S]{0,40}$/.test(pre))
|
|
179
|
+
return;
|
|
180
|
+
ocorrencias.push(warn(messages.ReactHooksMensagens.hookInConditional, relPath, line));
|
|
181
|
+
});
|
|
182
|
+
} // fim de if (!skipStructural)
|
|
98
183
|
// React 19: useOptimistic detected
|
|
99
184
|
for (const match of src.matchAll(/\buseOptimistic\s*\(/g)) {
|
|
100
185
|
const line = lineOf(match.index);
|
|
@@ -110,16 +195,12 @@ function collectHookIssues(src, relPath) {
|
|
|
110
195
|
const line = lineOf(match.index);
|
|
111
196
|
ocorrencias.push(warn('useFormStatus (React 19) detected - access pending form state', relPath, line, messages.SeverityNiveis.info));
|
|
112
197
|
}
|
|
113
|
-
// React 19: use() hook - suspends, must be in Suspense boundary
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
ocorrencias.push(warn('React 19 use() hook detected - must be wrapped in <Suspense> boundary', relPath, line, messages.SeverityNiveis.error));
|
|
122
|
-
}
|
|
198
|
+
// React 19: use() hook - suspends, must be in Suspense boundary.
|
|
199
|
+
// OBS: esta detecção por regex é intencionalmente OMITIDA aqui porque
|
|
200
|
+
// casaria falsamente com comentários/strings (ex.: "terms-of-use (...)") e
|
|
201
|
+
// com identificadores como "reuse(" / "abuse(". O hook use() é detectado de
|
|
202
|
+
// forma precisa via AST em parseHooksWithBabel (que ignora comentários),
|
|
203
|
+
// tornando este regex redundante e gerador de falsos positivos.
|
|
123
204
|
// Custom hooks naming convention
|
|
124
205
|
const customHooks = [...src.matchAll(/\buse[A-Z][A-Za-z0-9_]*\s*=\s*\(/g)];
|
|
125
206
|
customHooks.forEach(m => {
|
|
@@ -127,7 +208,7 @@ function collectHookIssues(src, relPath) {
|
|
|
127
208
|
if (!ALL_BUILTIN_HOOKS.includes(hookName)) {
|
|
128
209
|
if (/^use[A-Z]/.test(hookName)) {
|
|
129
210
|
const line = lineOf(m.index || 0);
|
|
130
|
-
ocorrencias.push(warn(`Custom hook '${hookName}' - verify naming follows useXxx convention`, relPath, line));
|
|
211
|
+
ocorrencias.push(warn(`Custom hook '${hookName}' - verify naming follows useXxx convention`, relPath, line, messages.SeverityNiveis.info));
|
|
131
212
|
}
|
|
132
213
|
}
|
|
133
214
|
});
|
|
@@ -182,7 +263,35 @@ function parseHooksWithBabel(src, relPath) {
|
|
|
182
263
|
const isMemoLike = (name) => name === 'useMemo' || name === 'useCallback';
|
|
183
264
|
const isAnyHook = (name) => /^use[A-Z0-9_]/.test(name) || name === 'use';
|
|
184
265
|
const inConditionalOrLoop = (path) => {
|
|
185
|
-
|
|
266
|
+
// Só viola as Rules of Hooks se o hook estiver DENTRO de um
|
|
267
|
+
// if/for/while/do-while/switch/conditional no MESMO escopo de função que o
|
|
268
|
+
// contém. Condicionais em funções pais (ex.: um `if` no componente que define
|
|
269
|
+
// um hook customizado como useMusicPlayer) são válidas e NÃO devem gerar falso
|
|
270
|
+
// positivo (feedback.md seção 1.2). Callbacks aninhados (outra função) também
|
|
271
|
+
// delimitam o escopo — um condicional dentro de um callback não conta para o
|
|
272
|
+
// hook de fora.
|
|
273
|
+
let current = path;
|
|
274
|
+
while (current) {
|
|
275
|
+
const parent = current.parentPath;
|
|
276
|
+
if (!parent) {
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
// Função que contém o hook: condicionais acima dela estão em escopo diferente.
|
|
280
|
+
if (parent.isFunction()) {
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
if (parent.isIfStatement()
|
|
284
|
+
|| parent.isForStatement()
|
|
285
|
+
|| parent.isWhileStatement()
|
|
286
|
+
|| parent.isDoWhileStatement()
|
|
287
|
+
|| parent.isSwitchStatement()
|
|
288
|
+
|| parent.isSwitchCase()
|
|
289
|
+
|| parent.isConditionalExpression()) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
current = parent;
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
186
295
|
};
|
|
187
296
|
const getIdentifiersInNode = (node) => {
|
|
188
297
|
const ids = new Set();
|
|
@@ -291,12 +400,14 @@ function parseHooksWithBabel(src, relPath) {
|
|
|
291
400
|
return !depsSet.has(r);
|
|
292
401
|
});
|
|
293
402
|
if (maybeMissing.length > 1) {
|
|
403
|
+
// Apenas useMemo/useCallback com deps faltando são "sem array de
|
|
404
|
+
// dependências". useEffect/useLayoutEffect QUE JÁ POSSUEM um array
|
|
405
|
+
// de deps (mesmo que incompleto) NÃO devem ser reportados como
|
|
406
|
+
// "sem array" — ver feedback.md seção 1.1. O aviso de stale closure
|
|
407
|
+
// para esses casos já é emitido por outro analisador.
|
|
294
408
|
if (isMemoLike(name)) {
|
|
295
409
|
pushOnce(warn(messages.ReactHooksMensagens.memoCallbackNoDeps, relPath, locLine));
|
|
296
410
|
}
|
|
297
|
-
else {
|
|
298
|
-
pushOnce(warn(messages.ReactHooksMensagens.useEffectNoDeps, relPath, locLine));
|
|
299
|
-
}
|
|
300
411
|
}
|
|
301
412
|
if (deps.elements.length === 0 && refsInCb.size > 0) {
|
|
302
413
|
const nonTrivialRefs = [...refsInCb].filter(r => {
|
|
@@ -329,7 +440,7 @@ function parseHooksWithBabel(src, relPath) {
|
|
|
329
440
|
}
|
|
330
441
|
}
|
|
331
442
|
if (isAnyHook(name) && !isEffectLike(name) && !isMemoLike(name) && name !== 'useState' && name !== 'useRef' && name !== 'useContext' && name !== 'useReducer' && name !== 'useDebugValue' && name !== 'useId' && name !== 'useOptimistic' && name !== 'useActionState' && name !== 'useFormStatus' && name !== 'use') {
|
|
332
|
-
pushOnce(warn(`Custom hook '${name}' usage detected`, relPath, locLine));
|
|
443
|
+
pushOnce(warn(`Custom hook '${name}' usage detected`, relPath, locLine, messages.SeverityNiveis.info));
|
|
333
444
|
}
|
|
334
445
|
}
|
|
335
446
|
catch {
|
|
@@ -362,7 +473,12 @@ export const analistaReactHooks = criarAnalista({
|
|
|
362
473
|
}
|
|
363
474
|
const astMsgs = parseHooksWithBabel(src, relPath);
|
|
364
475
|
if (astMsgs !== null) {
|
|
365
|
-
|
|
476
|
+
// O AST é a fonte autoritativa para verificações estruturais (deps de
|
|
477
|
+
// useEffect/useMemo e hooks em condicional). Quando disponível, o regex
|
|
478
|
+
// estrutural é ignorado para evitar os falsos positivos de análise por
|
|
479
|
+
// texto (ver feedback.md seção 1.1 e 1.2). As verificações informativas
|
|
480
|
+
// do regex permanecem como rede de segurança.
|
|
481
|
+
const regexMsgs = collectHookIssues(src, relPath, { skipStructural: true });
|
|
366
482
|
const all = [...astMsgs, ...regexMsgs];
|
|
367
483
|
const seen = new Set();
|
|
368
484
|
const deduped = all.filter(m => {
|
|
@@ -38,6 +38,11 @@ function collectReactIssues(src, relPath) {
|
|
|
38
38
|
// dangerouslySetInnerHTML
|
|
39
39
|
for (const match of src.matchAll(/dangerouslySetInnerHTML/gi)) {
|
|
40
40
|
const line = lineOf(match.index);
|
|
41
|
+
// Skip if this is a <script type="speculationrules"> element — dangerouslySetInnerHTML
|
|
42
|
+
// is required for Speculation Rules API (no children allowed, only innerHTML).
|
|
43
|
+
const lineContent = src.split('\n')[line - 1] ?? '';
|
|
44
|
+
if (/speculationrules/.test(lineContent))
|
|
45
|
+
continue;
|
|
41
46
|
ocorrencias.push(warn(messages.ReactMensagens.dangerouslySetInnerHTML, relPath, line));
|
|
42
47
|
}
|
|
43
48
|
// <img> sem alt
|
|
@@ -233,6 +238,13 @@ function parseReactWithBabel(scan, relPath) {
|
|
|
233
238
|
}
|
|
234
239
|
}
|
|
235
240
|
if (findAttr(attrs, 'dangerouslySetInnerHTML')) {
|
|
241
|
+
// Skip if this is a <script type="speculationrules"> — dangerouslySetInnerHTML
|
|
242
|
+
// is required for Speculation Rules API (no children, only innerHTML).
|
|
243
|
+
if (tag.toLowerCase() === 'script') {
|
|
244
|
+
const typeAttr = findAttr(attrs, 'type');
|
|
245
|
+
if (typeAttr && /speculationrules/i.test(normalizeStringValue(typeAttr.value)))
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
236
248
|
pushOnce(warn(messages.ReactMensagens.dangerouslySetInnerHTML, relPath, locLine));
|
|
237
249
|
}
|
|
238
250
|
// Inline Styles
|
|
@@ -9,6 +9,68 @@ const D = messages.DetectorReactMensagens;
|
|
|
9
9
|
function warn(tipo, mensagem, relPath, linha, sugestao, nivel = 'aviso') {
|
|
10
10
|
return criarOcorrencia({ tipo, nivel, mensagem, relPath, linha, sugestao });
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Decide se o valor passado a `dangerouslySetInnerHTML` é estático/controlado
|
|
14
|
+
* e, portanto, seguro o suficiente para NÃO gerar falso positivo.
|
|
15
|
+
* (ver feedback.md seção 1.3 — JSON-LD, string interna, conteúdo sanitizado).
|
|
16
|
+
*
|
|
17
|
+
* Consideramos controlado quando o valor é:
|
|
18
|
+
* - uma string literal (estática);
|
|
19
|
+
* - resultado de `JSON.stringify(...)` (ex.: JSON-LD / structured data);
|
|
20
|
+
* - resultado de uma chamada de sanitização/escape (`sanitize`, `escape`,
|
|
21
|
+
* `encode`, `secure`, etc.);
|
|
22
|
+
* - um template literal (conteúdo estático na prática).
|
|
23
|
+
*/
|
|
24
|
+
function isJsonStringifyCall(node) {
|
|
25
|
+
if (!t.isCallExpression(node)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const callee = node.callee;
|
|
29
|
+
return t.isMemberExpression(callee)
|
|
30
|
+
&& t.isIdentifier(callee.object)
|
|
31
|
+
&& callee.object.name === 'JSON'
|
|
32
|
+
&& t.isIdentifier(callee.property)
|
|
33
|
+
&& callee.property.name === 'stringify';
|
|
34
|
+
}
|
|
35
|
+
function calleeName(callee) {
|
|
36
|
+
if (t.isIdentifier(callee)) {
|
|
37
|
+
return callee.name;
|
|
38
|
+
}
|
|
39
|
+
if (t.isMemberExpression(callee) && t.isIdentifier(callee.property)) {
|
|
40
|
+
return callee.property.name;
|
|
41
|
+
}
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
function isControlledDangerousHtml(expr) {
|
|
45
|
+
if (!expr || t.isJSXEmptyExpression(expr)) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (t.isStringLiteral(expr)) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
if (t.isTemplateLiteral(expr)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (t.isCallExpression(expr)) {
|
|
55
|
+
if (isJsonStringifyCall(expr)) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
// JSON.stringify(data).replace(...) / .trim() — o objeto da chamada é JSON.stringify
|
|
59
|
+
if (t.isMemberExpression(expr.callee) && isJsonStringifyCall(expr.callee.object)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
const name = calleeName(expr.callee);
|
|
63
|
+
if (/sanit|escape|encod|secure|trust/i.test(name)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// JSON.stringify(...).replace(...) — o objeto da MemberExpression é a chamada
|
|
68
|
+
// JSON.stringify, então o conteúdo é estruturado/controlado (ex.: JSON-LD).
|
|
69
|
+
if (t.isMemberExpression(expr) && isJsonStringifyCall(expr.object)) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
12
74
|
export const detectorReactBestPractices = {
|
|
13
75
|
nome: 'detector-react-best-practices',
|
|
14
76
|
categoria: 'framework',
|
|
@@ -22,9 +84,6 @@ export const detectorReactBestPractices = {
|
|
|
22
84
|
const linhas = splitLines(src);
|
|
23
85
|
linhas.forEach((linha, index) => {
|
|
24
86
|
const numeroLinha = index + 1;
|
|
25
|
-
if (/dangerouslySetInnerHTML/.test(linha)) {
|
|
26
|
-
ocorrencias.push(warn('react-dangerous-html', D.dangerousHtml, relPath, numeroLinha, D.dangerousHtmlSugestao, 'erro'));
|
|
27
|
-
}
|
|
28
87
|
if (/onClick=|onSubmit=|onChange=|onMouseOver=/.test(linha) && /{[^}]*=>/.test(linha)) {
|
|
29
88
|
ocorrencias.push(warn('react-inline-handler', D.inlineHandler, relPath, numeroLinha, D.inlineHandlerSugestao, 'info'));
|
|
30
89
|
}
|
|
@@ -59,6 +118,21 @@ export const detectorReactBestPractices = {
|
|
|
59
118
|
}
|
|
60
119
|
}
|
|
61
120
|
}
|
|
121
|
+
// Fallback conservador: quando o parse por AST falha, ainda detectamos
|
|
122
|
+
// dangerouslySetInnerHTML de forma limitada, alertando apenas quando o
|
|
123
|
+
// valor claramente vem de fonte externa/não controlada (props, params,
|
|
124
|
+
// localStorage, fetch, location, etc). Conteúdo estático/controlado é
|
|
125
|
+
// ignorado (ver feedback.md seção 1.3 - ex.: JSON-LD).
|
|
126
|
+
if (!deeper) {
|
|
127
|
+
linhas.forEach((linha, index) => {
|
|
128
|
+
if (/dangerouslySetInnerHTML/.test(linha) && !/speculationrules/i.test(linha)) {
|
|
129
|
+
const numeroLinha = index + 1;
|
|
130
|
+
if (/\b(props|params|query|searchParams|localStorage|sessionStorage|fetch\(|\blocation\b|window\.|document\.cookie|req\.|res\.)/.test(linha)) {
|
|
131
|
+
ocorrencias.push(warn('react-dangerous-html', D.dangerousHtml, relPath, numeroLinha, D.dangerousHtmlSugestao, 'aviso'));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
62
136
|
return ocorrencias;
|
|
63
137
|
}
|
|
64
138
|
};
|
|
@@ -101,7 +175,27 @@ function detectWithBabel(src, relPath) {
|
|
|
101
175
|
}
|
|
102
176
|
const locLine = path.node.loc?.start.line;
|
|
103
177
|
if (isHookName(name)) {
|
|
104
|
-
|
|
178
|
+
// Only flag hooks inside conditionals in the SAME function scope.
|
|
179
|
+
// Stop climbing at function boundaries (callbacks, event handlers, etc.)
|
|
180
|
+
// to avoid false positives when a conditional is inside a hook callback.
|
|
181
|
+
const parent = (() => {
|
|
182
|
+
let currentPath = path;
|
|
183
|
+
while (currentPath) {
|
|
184
|
+
const found = currentPath.findParent(p => p.isIfStatement() || p.isConditionalExpression() || p.isLogicalExpression() || p.isSwitchStatement() || p.isSwitchCase() || p.isForStatement() || p.isWhileStatement() || p.isDoWhileStatement() || p.isLoop());
|
|
185
|
+
if (found)
|
|
186
|
+
return found;
|
|
187
|
+
// Stop at function boundaries
|
|
188
|
+
const fnBoundary = currentPath.findParent(p => p.isFunction());
|
|
189
|
+
if (!fnBoundary)
|
|
190
|
+
return null;
|
|
191
|
+
// Check above the function boundary
|
|
192
|
+
const aboveFn = fnBoundary.findParent(p => p.isIfStatement() || p.isConditionalExpression() || p.isLogicalExpression() || p.isSwitchStatement() || p.isSwitchCase() || p.isForStatement() || p.isWhileStatement() || p.isDoWhileStatement() || p.isLoop());
|
|
193
|
+
if (aboveFn)
|
|
194
|
+
return aboveFn;
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
})();
|
|
105
199
|
if (parent) {
|
|
106
200
|
pushOnce(warn('react-hook-conditional', D.hookConditionalCall, relPath, locLine, D.hookConditionalCallSugestao, 'erro'));
|
|
107
201
|
}
|
|
@@ -303,6 +397,29 @@ function detectWithBabel(src, relPath) {
|
|
|
303
397
|
pushOnce(warn('react-key-index', D.keyIndexUsage, relPath, locLine, D.keyIndexUsageSugestao, 'aviso'));
|
|
304
398
|
}
|
|
305
399
|
}
|
|
400
|
+
// dangerouslySetInnerHTML: só alerta quando o valor NÃO é estático/
|
|
401
|
+
// controlado (ex.: vem de props, params, fetch, localStorage). Conteúdo
|
|
402
|
+
// como JSON-LD (JSON.stringify) ou string interna é seguro e não deve
|
|
403
|
+
// gerar ruído (ver feedback.md seção 1.3).
|
|
404
|
+
if (name === 'dangerouslySetInnerHTML') {
|
|
405
|
+
const value = path.node.value;
|
|
406
|
+
let htmlExpr;
|
|
407
|
+
if (t.isJSXExpressionContainer(value)) {
|
|
408
|
+
const inner = value.expression;
|
|
409
|
+
if (t.isObjectExpression(inner)) {
|
|
410
|
+
const htmlProp = inner.properties?.find(p => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === '__html');
|
|
411
|
+
if (htmlProp && t.isObjectProperty(htmlProp) && !t.isPrivateName(htmlProp.value) && !t.isJSXEmptyExpression(htmlProp.value)) {
|
|
412
|
+
htmlExpr = htmlProp.value;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
else if (t.isExpression(inner)) {
|
|
416
|
+
htmlExpr = inner;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (!isControlledDangerousHtml(htmlExpr)) {
|
|
420
|
+
pushOnce(warn('react-dangerous-html', D.dangerousHtml, relPath, locLine, D.dangerousHtmlSugestao, 'aviso'));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
306
423
|
},
|
|
307
424
|
MemberExpression(path) {
|
|
308
425
|
const locLine = path.node.loc?.start.line;
|
|
@@ -311,8 +311,11 @@ function propertyKey(token) {
|
|
|
311
311
|
re: /^max-h(?:-|\[)/,
|
|
312
312
|
key: 'max-h'
|
|
313
313
|
}, {
|
|
314
|
-
re: /^(top|
|
|
315
|
-
key: 'position'
|
|
314
|
+
re: /^(top|bottom)(?:-|\[)/,
|
|
315
|
+
key: 'position-y'
|
|
316
|
+
}, {
|
|
317
|
+
re: /^(left|right)(?:-|\[)/,
|
|
318
|
+
key: 'position-x'
|
|
316
319
|
}, {
|
|
317
320
|
re: /^field-sizing(?:-|\[)/,
|
|
318
321
|
key: 'field-sizing'
|