Skip to content

Commit 900c813

Browse files
authored
Merge pull request #63 from smochan/feat/lint-db-call-in-loop
feat(lint): db-call-in-loop rule + loop_depth on call records
2 parents 2a0212e + 72cb9e5 commit 900c813

10 files changed

Lines changed: 408 additions & 8 deletions

File tree

codegraph/analysis/lint.py

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
415534
def _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

codegraph/parsers/python.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,6 +1319,11 @@ def _visit_nested_defs(
13191319
continue
13201320
stack.extend(node.children)
13211321

1322+
# Python loop node types that increase loop depth for their children.
1323+
_PY_LOOP_TYPES: frozenset[str] = frozenset(
1324+
{"for_statement", "while_statement"}
1325+
)
1326+
13221327
def _collect_calls(
13231328
self,
13241329
node: tree_sitter.Node,
@@ -1328,9 +1333,11 @@ def _collect_calls(
13281333
edges: list[Edge],
13291334
) -> None:
13301335
"""Walk subtree collecting call expressions, stopping at nested defs."""
1331-
stack: list[tree_sitter.Node] = list(node.children)
1336+
stack: list[tuple[tree_sitter.Node, int]] = [
1337+
(child, 0) for child in node.children
1338+
]
13321339
while stack:
1333-
child = stack.pop()
1340+
child, depth = stack.pop()
13341341
if child.type == "call":
13351342
func_child = child.child_by_field_name("function")
13361343
if func_child is None and child.children:
@@ -1352,6 +1359,8 @@ def _collect_calls(
13521359
"target_name": name,
13531360
"args": args,
13541361
"kwargs": kwargs,
1362+
"loop_depth": depth,
1363+
"in_loop": depth > 0,
13551364
},
13561365
))
13571366
# ``decorator`` subtrees are handled by ``_emit_decorator_calls``
@@ -1361,7 +1370,10 @@ def _collect_calls(
13611370
if child.type not in (
13621371
"class_definition", "function_definition", "decorator",
13631372
):
1364-
stack.extend(child.children)
1373+
child_depth = (
1374+
depth + 1 if child.type in self._PY_LOOP_TYPES else depth
1375+
)
1376+
stack.extend((gc, child_depth) for gc in child.children)
13651377

13661378
def _emit_decorator_calls(
13671379
self,

codegraph/parsers/typescript.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,20 @@ def _handle_lexical_decl(
10171017
self._collect_calls(body, rel, func_id, src, edges)
10181018
self._collect_fetches(body, rel, func_id, src, nodes, edges)
10191019

1020+
# Higher-order iteration methods whose function-typed arguments are treated
1021+
# as implicit loop bodies for loop-depth tracking purposes.
1022+
_ITER_METHODS: frozenset[str] = frozenset(
1023+
{"map", "forEach", "filter", "reduce", "flatMap"}
1024+
)
1025+
# TS/JS loop node types that increase loop depth for their children.
1026+
_TS_LOOP_TYPES: frozenset[str] = frozenset(
1027+
{"for_statement", "for_in_statement", "while_statement", "do_statement"}
1028+
)
1029+
# Function-typed AST node types that can appear as callbacks.
1030+
_FUNC_ARG_TYPES: frozenset[str] = frozenset(
1031+
{"arrow_function", "function_expression", "function"}
1032+
)
1033+
10201034
def _collect_calls(
10211035
self,
10221036
node: tree_sitter.Node,
@@ -1025,9 +1039,11 @@ def _collect_calls(
10251039
src: bytes,
10261040
edges: list[Edge],
10271041
) -> None:
1028-
stack: list[tree_sitter.Node] = list(node.children)
1042+
stack: list[tuple[tree_sitter.Node, int]] = [
1043+
(child, 0) for child in node.children
1044+
]
10291045
while stack:
1030-
child = stack.pop()
1046+
child, depth = stack.pop()
10311047
if child.type == "call_expression":
10321048
func_child = child.child_by_field_name("function")
10331049
if func_child is None and child.children:
@@ -1056,9 +1072,37 @@ def _collect_calls(
10561072
"target_name": name,
10571073
"args": call_args,
10581074
"kwargs": call_kwargs,
1075+
"loop_depth": depth,
1076+
"in_loop": depth > 0,
10591077
},
10601078
))
1061-
stack.extend(child.children)
1079+
# Determine whether this call is an iteration method
1080+
# (.map/.forEach/.filter/.reduce/.flatMap) and bump depth
1081+
# for any function-typed arguments.
1082+
iter_method = ""
1083+
if func_child is not None and func_child.type == "member_expression":
1084+
prop_node = func_child.child_by_field_name("property")
1085+
if prop_node is not None:
1086+
iter_method = node_text(prop_node, src)
1087+
if iter_method in self._ITER_METHODS and args_node is not None:
1088+
# The callee subtree may itself contain calls
1089+
# (e.g. ``getUsers().map(cb)``) — keep visiting it
1090+
# at the current depth.
1091+
stack.extend(
1092+
(gc, depth) for gc in func_child.children
1093+
)
1094+
for arg in args_node.children:
1095+
if arg.type in self._FUNC_ARG_TYPES:
1096+
stack.extend(
1097+
(gc, depth + 1) for gc in arg.children
1098+
)
1099+
elif arg.type not in (",", "(", ")"):
1100+
stack.extend((gc, depth) for gc in arg.children)
1101+
continue
1102+
child_depth = (
1103+
depth + 1 if child.type in self._TS_LOOP_TYPES else depth
1104+
)
1105+
stack.extend((gc, child_depth) for gc in child.children)
10621106

10631107
# ------------------------------------------------------------------
10641108
# DF2: HTTP call-site detection (fetch / axios / SWR / api-clients)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Fixture for loop_depth / in_loop metadata tests.
2+
3+
export function withLoops(items: string[]): void {
4+
for (const item of items) {
5+
doWork(item);
6+
}
7+
8+
items.forEach((x) => {
9+
doWork(x);
10+
});
11+
}
12+
13+
export function withoutLoop(): void {
14+
doWork("direct");
15+
}
16+
17+
export function chainedBeforeMap(): void {
18+
getUsers().map((u) => doWork(u));
19+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from db import session
2+
3+
4+
# for loop with session.execute — should fire db-call-in-loop
5+
def process_items(items):
6+
for item in items:
7+
session.execute("INSERT INTO log VALUES (?)", [item])
8+
9+
10+
# non-loop call — must NOT fire
11+
def setup():
12+
session.execute("CREATE TABLE IF NOT EXISTS log (val TEXT)")
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { db, jobs } from "./schema";
2+
3+
// for...of loop — should fire db-call-in-loop
4+
export async function insertAll(items: string[]): Promise<void> {
5+
for (const item of items) {
6+
await db.insert(jobs).values(item);
7+
}
8+
}
9+
10+
// .forEach callback — should fire db-call-in-loop
11+
export async function updateAll(ids: number[]): Promise<void> {
12+
ids.forEach(async (id) => {
13+
await db.update(jobs).set({ processed: true }).where(id);
14+
});
15+
}
16+
17+
// non-loop call — must NOT fire
18+
export async function insertOne(item: string): Promise<void> {
19+
await db.insert(jobs).values(item);
20+
}

tests/test_df0_python.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,42 @@ def test_call_literal_kinds_captured(
185185
_, edges = _run_extractor_on_source(tmp_path, src)
186186
call = next(e for e in _calls_edges(edges) if e.metadata.get("target_name") == "f")
187187
assert call.metadata["args"] == [expected]
188+
189+
190+
# --- loop_depth / in_loop metadata ----------------------------------------
191+
192+
193+
def test_call_inside_for_loop_has_loop_depth(tmp_path: Path) -> None:
194+
src = "def caller(items):\n for item in items:\n f(item)\n"
195+
_, edges = _run_extractor_on_source(tmp_path, src)
196+
call = next(e for e in _calls_edges(edges) if e.metadata.get("target_name") == "f")
197+
assert call.metadata["loop_depth"] == 1
198+
assert call.metadata["in_loop"] is True
199+
200+
201+
def test_call_inside_while_loop_has_loop_depth(tmp_path: Path) -> None:
202+
src = "def caller(cond):\n while cond:\n f()\n"
203+
_, edges = _run_extractor_on_source(tmp_path, src)
204+
call = next(e for e in _calls_edges(edges) if e.metadata.get("target_name") == "f")
205+
assert call.metadata["loop_depth"] == 1
206+
assert call.metadata["in_loop"] is True
207+
208+
209+
def test_call_outside_loop_has_zero_loop_depth(tmp_path: Path) -> None:
210+
src = "def caller():\n f()\n"
211+
_, edges = _run_extractor_on_source(tmp_path, src)
212+
call = next(e for e in _calls_edges(edges) if e.metadata.get("target_name") == "f")
213+
assert call.metadata["loop_depth"] == 0
214+
assert call.metadata["in_loop"] is False
215+
216+
217+
def test_nested_loop_increments_depth(tmp_path: Path) -> None:
218+
src = (
219+
"def caller(matrix):\n"
220+
" for row in matrix:\n"
221+
" for cell in row:\n"
222+
" f(cell)\n"
223+
)
224+
_, edges = _run_extractor_on_source(tmp_path, src)
225+
call = next(e for e in _calls_edges(edges) if e.metadata.get("target_name") == "f")
226+
assert call.metadata["loop_depth"] == 2

0 commit comments

Comments
 (0)