@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.
@@ -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
- // SPDX-License-Identifier: MIT
2
- // @mahoraga-disable ml-deep-nesting
3
- // Justificativa: O detector de aninhamento excessivo analisa o próprio código gerando falso positivo em auto-análise. O padrão deep-nesting é necessário para detecção de bugs.
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, _relPath) {
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
- let lineCount = 1;
188
- for (const char of content) {
189
- if (char === '\n') {
190
- lineCount++;
191
- }
192
- if (char === '{' || char === '(' || char === '[') {
193
- currentDepth++;
194
- if (currentDepth > maxDepth) {
195
- maxDepth = currentDepth;
196
- depthLine = lineCount;
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
- if (maxDepth > 6) {
217
+ // Limiar maior: 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.find(arquivo => arquivo.relPath === arquivoPrincipal)?.content ?? '';
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
- // Check useEffect/useLayoutEffect for deps
58
- const effectMatches = [...src.matchAll(/use(Layout)?Effect\s*\(/g)];
59
- effectMatches.forEach(m => {
60
- const hookInicio = m.index;
61
- const fullCall = extractHookCallContent(src, hookInicio + m[0].length - 1);
62
- const hasDepsArg = /,\s*(\[[\s\S]*?\]|\w+)\s*\)$/.test(fullCall);
63
- const skip = /no-deps-ok|eslint-disable-next-line\s+react-hooks\/exhaustive-deps/i.test(fullCall);
64
- if (!hasDepsArg && !skip) {
65
- const line = lineOf(hookInicio);
66
- ocorrencias.push(warn(messages.ReactHooksMensagens.useEffectNoDeps, relPath, line));
67
- }
68
- if (hasDepsArg && /\[\s*\]/.test(fullCall)) {
69
- const bodyWithRefs = fullCall.match(/^[\s\S]*?(?=,\s*\[)/);
70
- if (bodyWithRefs) {
71
- const varRefs = bodyWithRefs[0].match(/\b([a-z]\w+)\b/g) ?? [];
72
- const nonTrivialRefs = varRefs.filter(v => !/^(set|use)/.test(v) && v.length > 1 && !['undefined', 'null', 'true', 'false'].includes(v));
73
- if (nonTrivialRefs.length > 0) {
74
- const line = lineOf(hookInicio);
75
- ocorrencias.push(warn('Empty deps array but effect references component variables', relPath, line));
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
- // useMemo/useCallback deps check
81
- const memoMatches = [...src.matchAll(/use(Memo|Callback)\s*\(/g)];
82
- memoMatches.forEach(m => {
83
- const hookInicio = m.index;
84
- const fullCall = extractHookCallContent(src, hookInicio + m[0].length - 1);
85
- const hasDepsArg = /,\s*(\[[\s\S]*?\]|\w+)\s*\)$/.test(fullCall);
86
- const skip = /no-deps-ok|eslint-disable-next-line\s+react-hooks\/exhaustive-deps/i.test(fullCall);
87
- if (!hasDepsArg && !skip) {
88
- const line = lineOf(hookInicio);
89
- ocorrencias.push(warn(messages.ReactHooksMensagens.memoCallbackNoDeps, relPath, line));
90
- }
91
- });
92
- // Conditional hooks
93
- const conditionalHooks = [...src.matchAll(/(if|for|while)\s*\([^)]*\)\s*\{[\s\S]{0,160}?use[A-Z][A-Za-z0-9_]*/g)];
94
- conditionalHooks.forEach(m => {
95
- const line = lineOf(m.index || 0);
96
- ocorrencias.push(warn(messages.ReactHooksMensagens.hookInConditional, relPath, line));
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
- for (const match of src.matchAll(/\buse\s*\(/g)) {
115
- const line = lineOf(match.index);
116
- // Avoid matching "useState", "useEffect", etc.
117
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime safety for match.index === 0
118
- if (src[match.index - 1]?.match(/[a-zA-Z]/)) {
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
- return Boolean(path.findParent(p => p.isIfStatement() || p.isForStatement() || p.isWhileStatement() || p.isDoWhileStatement() || p.isSwitchStatement() || p.isConditionalExpression()));
266
+ // 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
- const regexMsgs = collectHookIssues(src, relPath);
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
- const parent = path.findParent(p => p.isIfStatement() || p.isConditionalExpression() || p.isLogicalExpression() || p.isSwitchStatement() || p.isSwitchCase() || p.isForStatement() || p.isWhileStatement() || p.isDoWhileStatement() || p.isLoop());
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|left|right|bottom)(?:-|\[)/,
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'