@@ -318,10 +318,84 @@ def _check_sensitive_literal(
318318 )
319319
320320
321+ _TS_DB_PATTERN = r"\b(db|database)\.(select|insert|update|delete|execute|query)\b"
322+ _PY_DB_PATTERN = r"\b(session|cursor|db)\.(query|execute|add|commit)\b"
323+
324+ # Higher-order iteration methods treated as implicit loop bodies.
325+ _TS_ITER_METHODS = frozenset ({"map" , "forEach" , "filter" , "reduce" , "flatMap" })
326+ # Function-typed AST node types that can appear as callbacks.
327+ _FUNC_NODE_TYPES = frozenset ({"arrow_function" , "function_expression" , "function" })
328+
329+
330+ def _py_chain_text (node : tree_sitter .Node , src : bytes ) -> str | None :
331+ """Flatten a Python attribute-access chain to dotted text.
332+
333+ For a ``call`` node whose ``function`` child is an ``attribute``,
334+ walks the attribute chain to produce e.g. ``session.execute``.
335+ Returns ``None`` when the chain doesn't bottom out at an identifier.
336+ """
337+ parts : list [str ] = []
338+ cur : tree_sitter .Node | None = node .child_by_field_name ("function" )
339+ while cur is not None :
340+ if cur .type == "attribute" :
341+ attr = cur .child_by_field_name ("attribute" )
342+ if attr is not None :
343+ parts .append (node_text (attr , src ))
344+ cur = cur .child_by_field_name ("object" )
345+ elif cur .type in ("identifier" ,):
346+ parts .append (node_text (cur , src ))
347+ return "." .join (reversed (parts ))
348+ else :
349+ return None
350+ return None
351+
352+
353+ def _check_db_call_in_loop (
354+ node : tree_sitter .Node , ctx : _LintContext
355+ ) -> LintFinding | None :
356+ """DB call inside a loop body — potential N+1 query."""
357+ if ctx .is_test_file or ctx .loop_depth < 1 :
358+ return None
359+
360+ if ctx .language in _TS_LANGS :
361+ if node .type != "call_expression" or not _is_chain_root (node ):
362+ return None
363+ chain = _member_chain (node , ctx .src )
364+ if chain is None :
365+ return None
366+ base , methods = chain
367+ chain_text = "." .join ([base , * methods ])
368+ pattern = str (ctx .rule .options .get ("pattern" ) or _TS_DB_PATTERN )
369+ elif ctx .language == "python" :
370+ if node .type != "call" :
371+ return None
372+ chain_text = _py_chain_text (node , ctx .src ) or ""
373+ if not chain_text :
374+ return None
375+ pattern = str (ctx .rule .options .get ("pattern" ) or _PY_DB_PATTERN )
376+ else :
377+ return None
378+
379+ if not re .search (pattern , chain_text ):
380+ return None
381+
382+ return LintFinding (
383+ rule_id = ctx .rule .id ,
384+ severity = ctx .rule .severity ,
385+ message = _format_message (
386+ ctx .rule , chain = chain_text , depth = ctx .loop_depth
387+ ),
388+ file = ctx .rel_path ,
389+ line = node .start_point [0 ] + 1 ,
390+ snippet = _snippet (node , ctx .src ),
391+ )
392+
393+
321394_CHECK_REGISTRY : dict [str , _Checker ] = {
322395 "console-in-prod" : _check_console_in_prod ,
323396 "unfiltered-query" : _check_unfiltered_query ,
324397 "sensitive-literal" : _check_sensitive_literal ,
398+ "db-call-in-loop" : _check_db_call_in_loop ,
325399}
326400
327401
@@ -344,6 +418,12 @@ def _check_sensitive_literal(
344418 severity = "med" ,
345419 message = "Literal assigned to `{name}` looks sensitive ({reason})" ,
346420 ),
421+ LintRule (
422+ id = "db-call-in-loop" ,
423+ check = "db-call-in-loop" ,
424+ severity = "high" ,
425+ message = "DB call `{chain}` inside a loop (N+1 risk)" ,
426+ ),
347427]
348428
349429
@@ -412,6 +492,45 @@ def _is_test_path(rel_path: str, language: str) -> bool:
412492 return _is_ts_test_file (rel_path )
413493
414494
495+ def _iter_children_with_depth (
496+ node : tree_sitter .Node , base_depth : int , language : str , src : bytes
497+ ) -> list [tuple [tree_sitter .Node , int ]]:
498+ """Return ``(child, depth)`` pairs for a node's children.
499+
500+ For TS/JS ``call_expression`` nodes whose member property is one of the
501+ higher-order iteration methods (.map, .forEach, .filter, .reduce,
502+ .flatMap), function-typed arguments receive ``base_depth + 1`` so the
503+ DFS treats their bodies as implicit loop bodies. All other children
504+ keep ``base_depth``.
505+ """
506+ if language not in _TS_LANGS or node .type != "call_expression" :
507+ return [(child , base_depth ) for child in reversed (node .children )]
508+
509+ fn = node .child_by_field_name ("function" )
510+ if fn is None or fn .type != "member_expression" :
511+ return [(child , base_depth ) for child in reversed (node .children )]
512+
513+ prop = fn .child_by_field_name ("property" )
514+ if prop is None or node_text (prop , src ) not in _TS_ITER_METHODS :
515+ return [(child , base_depth ) for child in reversed (node .children )]
516+
517+ args_node = node .child_by_field_name ("arguments" )
518+ if args_node is None :
519+ return [(child , base_depth ) for child in reversed (node .children )]
520+
521+ result : list [tuple [tree_sitter .Node , int ]] = []
522+ for child in reversed (node .children ):
523+ if child == args_node :
524+ for arg in reversed (child .children ):
525+ if arg .type in _FUNC_NODE_TYPES :
526+ result .append ((arg , base_depth + 1 ))
527+ else :
528+ result .append ((arg , base_depth ))
529+ else :
530+ result .append ((child , base_depth ))
531+ return result
532+
533+
415534def _lint_file (
416535 path : Path , rel_path : str , language : str , rules : list [LintRule ]
417536) -> list [LintFinding ]:
@@ -450,8 +569,13 @@ def _lint_file(
450569 child_depth = (
451570 loop_depth + 1 if node .type in _LOOP_NODE_TYPES else loop_depth
452571 )
453- for child in reversed (node .children ):
454- stack .append ((child , child_depth ))
572+ # For .map/.forEach/.filter/.reduce/.flatMap calls (TS only), treat
573+ # function-typed arguments as implicit loop bodies.
574+ children_with_depth = _iter_children_with_depth (
575+ node , child_depth , language , src
576+ )
577+ for child , cd in children_with_depth :
578+ stack .append ((child , cd ))
455579 return findings
456580
457581
0 commit comments