Skip to content

Commit cc8ed0f

Browse files
committed
fix(deadcode): false positive on early return + multi-line dict return (closes #105)
Root cause: when a multi-line (open brace > close brace on the start line) was encountered, the scanner skipped the line via WITHOUT resetting . If a previous had set the terminal flag, the multi-line return body (e.g. dict literals) was falsely flagged as unreachable code. Example false positive (the exact pattern from analyze.py before this fix): def _detect_vulns(workspace, max_items): ... if total == 0: return None return { # <- multiline start, was skipped "category": "vulns", # <- falsely flagged as unreachable "total": total, } Fix: in the multi-line return skip path (Python), check the current line's indent vs the previous terminal's indent. If the current return is in an outer scope (lower indent), the previous terminal is no longer relevant — reset found_terminal before continuing. Also reverts the antipattern introduced in PR #96 across 8 functions in scripts/commands/analyze.py back to the PEP 8-friendly form. The antipattern was only there to satisfy the buggy scanner; with the fix the PEP 8 form scans cleanly. Tests: 5 new regression tests in tests/test_deadcode_engine.py covering simple/chained/nested early returns + multi-line dict return + a sanity check that genuinely unreachable code is still detected. All 14 deadcode tests pass.
1 parent a875428 commit cc8ed0f

3 files changed

Lines changed: 208 additions & 90 deletions

File tree

scripts/commands/analyze.py

Lines changed: 82 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -454,17 +454,16 @@ def _detect_vulns(workspace: str, max_items: int) -> Optional[Dict]:
454454
total = vuln.get("stats", {}).get("total_vulnerabilities", 0)
455455
if total == 0:
456456
return None
457-
else:
458-
return {
459-
"category": "vulnerabilities",
460-
"label": "Known CVEs",
461-
"total": total,
462-
"severity": "critical",
463-
"by_severity": vuln.get("stats", {}).get("by_severity", {}),
464-
"top_items": vuln.get("vulnerabilities", [])[:max_items],
465-
"action": "Update vulnerable dependencies immediately — check npm audit, pip audit, cargo audit, or govulncheck",
466-
"impact": "Known vulnerabilities can be exploited by attackers even without source code access",
467-
}
457+
return {
458+
"category": "vulnerabilities",
459+
"label": "Known CVEs",
460+
"total": total,
461+
"severity": "critical",
462+
"by_severity": vuln.get("stats", {}).get("by_severity", {}),
463+
"top_items": vuln.get("vulnerabilities", [])[:max_items],
464+
"action": "Update vulnerable dependencies immediately — check npm audit, pip audit, cargo audit, or govulncheck",
465+
"impact": "Known vulnerabilities can be exploited by attackers even without source code access",
466+
}
468467

469468

470469
def _detect_dataflow(workspace: str, max_items: int) -> Optional[Dict]:
@@ -473,16 +472,15 @@ def _detect_dataflow(workspace: str, max_items: int) -> Optional[Dict]:
473472
violations = df.get("stats", {}).get("violations", 0)
474473
if violations == 0:
475474
return None
476-
else:
477-
return {
478-
"category": "dataflow_violations",
479-
"label": "Unsafe Data Flows",
480-
"total": violations,
481-
"severity": "high",
482-
"top_items": df.get("violations", [])[:max_items],
483-
"action": "Add input sanitization and output encoding at every source→sink boundary",
484-
"impact": "Untainted data flows can lead to SQL injection, XSS, and command injection attacks",
485-
}
475+
return {
476+
"category": "dataflow_violations",
477+
"label": "Unsafe Data Flows",
478+
"total": violations,
479+
"severity": "high",
480+
"top_items": df.get("violations", [])[:max_items],
481+
"action": "Add input sanitization and output encoding at every source→sink boundary",
482+
"impact": "Untainted data flows can lead to SQL injection, XSS, and command injection attacks",
483+
}
486484

487485

488486
def _detect_env(workspace: str, max_items: int) -> Optional[Dict]:
@@ -495,20 +493,19 @@ def _detect_env(workspace: str, max_items: int) -> Optional[Dict]:
495493
issues = undocumented # Each undocumented var is an issue
496494
if issues == 0 and total_vars == 0:
497495
return None
498-
else:
499-
return {
500-
"category": "env_issues",
501-
"label": "Environment Issues",
502-
"total": issues,
503-
"severity": "medium",
504-
"top_items": [{"name": v.get("name"), "is_required": v.get("is_required"),
505-
"has_fallback": v.get("has_fallback"),
506-
"documentation": v.get("documentation")}
507-
for v in env.get("variables", [])[:max_items]
508-
if not v.get("documentation")],
509-
"action": "Review .env files, ensure secrets are not committed, add .env to .gitignore",
510-
"impact": "Misconfigured environment variables can leak secrets or cause runtime failures",
511-
}
496+
return {
497+
"category": "env_issues",
498+
"label": "Environment Issues",
499+
"total": issues,
500+
"severity": "medium",
501+
"top_items": [{"name": v.get("name"), "is_required": v.get("is_required"),
502+
"has_fallback": v.get("has_fallback"),
503+
"documentation": v.get("documentation")}
504+
for v in env.get("variables", [])[:max_items]
505+
if not v.get("documentation")],
506+
"action": "Review .env files, ensure secrets are not committed, add .env to .gitignore",
507+
"impact": "Misconfigured environment variables can leak secrets or cause runtime failures",
508+
}
512509

513510

514511
def _detect_smells(workspace: str, severity_filter: set, max_items: int) -> Optional[Dict]:
@@ -561,17 +558,16 @@ def _detect_complexity(workspace: str, max_items: int) -> Optional[Dict]:
561558
hotspots = comp.get("hotspots", [])
562559
if not hotspots:
563560
return None
564-
else:
565-
return {
566-
"category": "complexity",
567-
"label": "Complexity Hotspots",
568-
"total": len(hotspots),
569-
"severity": "high" if any(h.get("cyclomatic", 0) > 20 for h in hotspots) else "medium",
570-
"avg_cyclomatic": comp.get("stats", {}).get("avg_cyclomatic", 0),
571-
"top_items": hotspots[:max_items],
572-
"action": "Refactor high-complexity functions by extracting helper methods, reducing branches, and simplifying conditionals",
573-
"impact": "Complex functions are bug magnets — they're hard to test, understand, and maintain",
574-
}
561+
return {
562+
"category": "complexity",
563+
"label": "Complexity Hotspots",
564+
"total": len(hotspots),
565+
"severity": "high" if any(h.get("cyclomatic", 0) > 20 for h in hotspots) else "medium",
566+
"avg_cyclomatic": comp.get("stats", {}).get("avg_cyclomatic", 0),
567+
"top_items": hotspots[:max_items],
568+
"action": "Refactor high-complexity functions by extracting helper methods, reducing branches, and simplifying conditionals",
569+
"impact": "Complex functions are bug magnets — they're hard to test, understand, and maintain",
570+
}
575571

576572

577573
def _detect_dead_code(workspace: str, max_items: int) -> Optional[Dict]:
@@ -580,17 +576,16 @@ def _detect_dead_code(workspace: str, max_items: int) -> Optional[Dict]:
580576
total = dc.get("stats", {}).get("total_dead_code", 0)
581577
if total == 0:
582578
return None
583-
else:
584-
return {
585-
"category": "dead_code",
586-
"label": "Dead Code",
587-
"total": total,
588-
"severity": "medium",
589-
"by_category": dc.get("stats", {}).get("by_category", {}),
590-
"top_items": dc.get("results", {}).get("unreachable", [])[:max_items],
591-
"action": "Remove dead code in batches with testing — start with unreachable code and unused exports",
592-
"impact": "Dead code increases maintenance burden, confuses new developers, and bloats the codebase",
593-
}
579+
return {
580+
"category": "dead_code",
581+
"label": "Dead Code",
582+
"total": total,
583+
"severity": "medium",
584+
"by_category": dc.get("stats", {}).get("by_category", {}),
585+
"top_items": dc.get("results", {}).get("unreachable", [])[:max_items],
586+
"action": "Remove dead code in batches with testing — start with unreachable code and unused exports",
587+
"impact": "Dead code increases maintenance burden, confuses new developers, and bloats the codebase",
588+
}
594589

595590

596591
def _detect_circular(workspace: str, max_items: int) -> Optional[Dict]:
@@ -623,17 +618,16 @@ def _detect_perf(workspace: str, max_items: int) -> Optional[Dict]:
623618
total = perf.get("stats", {}).get("total_hints", 0)
624619
if total == 0:
625620
return None
626-
else:
627-
return {
628-
"category": "perf_hints",
629-
"label": "Performance Issues",
630-
"total": total,
631-
"severity": perf.get("risk", "low"),
632-
"by_category": perf.get("stats", {}).get("by_category", {}),
633-
"top_items": perf.get("hints", [])[:max_items],
634-
"action": "Address N+1 queries first (critical), then sync blocking, then memory leaks",
635-
"impact": "Performance issues compound — N+1 queries scale linearly with data size, blocking calls freeze the event loop",
636-
}
621+
return {
622+
"category": "perf_hints",
623+
"label": "Performance Issues",
624+
"total": total,
625+
"severity": perf.get("risk", "low"),
626+
"by_category": perf.get("stats", {}).get("by_category", {}),
627+
"top_items": perf.get("hints", [])[:max_items],
628+
"action": "Address N+1 queries first (critical), then sync blocking, then memory leaks",
629+
"impact": "Performance issues compound — N+1 queries scale linearly with data size, blocking calls freeze the event loop",
630+
}
637631

638632

639633
def _detect_config_drift(workspace: str, max_items: int) -> Optional[Dict]:
@@ -642,16 +636,15 @@ def _detect_config_drift(workspace: str, max_items: int) -> Optional[Dict]:
642636
total = drift.get("stats", {}).get("total_drift_items", 0)
643637
if total == 0:
644638
return None
645-
else:
646-
return {
647-
"category": "config_drift",
648-
"label": "Dependency Drift",
649-
"total": total,
650-
"severity": "low",
651-
"top_items": drift.get("drift_items", [])[:max_items],
652-
"action": "Update outdated dependencies to reduce security risk and get bug fixes",
653-
"impact": "Outdated dependencies may contain unpatched security vulnerabilities",
654-
}
639+
return {
640+
"category": "config_drift",
641+
"label": "Dependency Drift",
642+
"total": total,
643+
"severity": "low",
644+
"top_items": drift.get("drift_items", [])[:max_items],
645+
"action": "Update outdated dependencies to reduce security risk and get bug fixes",
646+
"impact": "Outdated dependencies may contain unpatched security vulnerabilities",
647+
}
655648

656649

657650
def _detect_binaries(workspace: str, max_items: int) -> Optional[Dict]:
@@ -660,18 +653,17 @@ def _detect_binaries(workspace: str, max_items: int) -> Optional[Dict]:
660653
total = bins.get("stats", {}).get("total_artifacts", 0)
661654
if total == 0:
662655
return None
663-
else:
664-
return {
665-
"category": "binary_artifacts",
666-
"label": "Binary/Compiled Files",
667-
"total": total,
668-
"severity": "low",
669-
"by_category": bins.get("stats", {}).get("by_category", {}),
670-
"top_items": bins.get("findings", [])[:max_items],
671-
"recommendations": bins.get("recommendations", []),
672-
"action": "Add binary files to .gitignore and use build pipelines instead",
673-
"impact": "Binary files bloat the repository, make diffs meaningless, and may contain vulnerable code",
674-
}
656+
return {
657+
"category": "binary_artifacts",
658+
"label": "Binary/Compiled Files",
659+
"total": total,
660+
"severity": "low",
661+
"by_category": bins.get("stats", {}).get("by_category", {}),
662+
"top_items": bins.get("findings", [])[:max_items],
663+
"recommendations": bins.get("recommendations", []),
664+
"action": "Add binary files to .gitignore and use build pipelines instead",
665+
"impact": "Binary files bloat the repository, make diffs meaningless, and may contain vulnerable code",
666+
}
675667

676668

677669
# ─── Helper Functions ──────────────────────────────────────

scripts/deadcode_engine.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,22 @@ def _detect_unreachable_code(content: str, ext: str, rel_path: str) -> List[Dict
461461
open_count = stripped.count('(') + stripped.count('[') + stripped.count('{')
462462
close_count = stripped.count(')') + stripped.count(']') + stripped.count('}')
463463
if open_count > close_count:
464+
# v10 (issue #105): Before skipping, check if we've already
465+
# exited the block that contained the previous terminal
466+
# statement. The classic false-positive pattern is:
467+
# if x:
468+
# return None # terminal at indent 8
469+
# return { # indent 4 — multiline start
470+
# "k": "v", # indent 8 — was flagged as
471+
# } # unreachable (same indent
472+
# # as the terminal inside if)
473+
# The previous terminal was inside an `if` block; the
474+
# current return is in the outer scope (lower indent),
475+
# so the previous terminal is no longer relevant.
476+
# Reset it so the multi-line return body is not flagged.
477+
current_indent = len(line) - len(line.lstrip())
478+
if found_terminal and terminal_indent > 0 and current_indent < terminal_indent:
479+
found_terminal = False
464480
continue # Return expression continues on the next line
465481
found_terminal = True
466482
terminal_line = i # 0-based: next line has i+1 > i = True

tests/test_deadcode_engine.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,113 @@ def test_empty_workspace(self):
180180
assert result["stats"]["total_dead_code"] == 0
181181
finally:
182182
shutil.rmtree(ws, ignore_errors=True)
183+
184+
# ─── Issue #105 regression tests ────────────────────────────────
185+
# These patterns must NOT be flagged as unreachable. They are the
186+
# PEP 8-friendly early-return pattern that workers were previously
187+
# forced to wrap in `else:` to satisfy the scanner.
188+
189+
def test_issue_105_early_return_then_final_return(self):
190+
"""Early return inside `if` + final return after should NOT be flagged."""
191+
code = """def f(condition):
192+
if condition:
193+
return None
194+
return {"key": "value"}
195+
"""
196+
ws = self._create_workspace(code, "app.py")
197+
try:
198+
result = detect_dead_code(ws)
199+
assert result["status"] == "ok"
200+
unreachable = result.get("results", {}).get("unreachable", [])
201+
assert len(unreachable) == 0, \
202+
f"False positive: {unreachable}"
203+
finally:
204+
shutil.rmtree(ws, ignore_errors=True)
205+
206+
def test_issue_105_multiline_return_after_early_return(self):
207+
"""Multi-line dict return after early return should NOT be flagged.
208+
209+
This is the exact reproduction of issue #105. Before the fix, the
210+
scanner reported line 5 (the dict body) as unreachable because the
211+
multi-line return detection skipped the `return {` line without
212+
resetting the terminal flag from the previous `return None` inside
213+
the `if` block.
214+
"""
215+
code = """def _detect_vulns(workspace, max_items):
216+
from vulnscan_engine import scan_vulnerabilities
217+
vuln = scan_vulnerabilities(workspace)
218+
total = vuln.get("stats", {}).get("total_vulnerabilities", 0)
219+
if total == 0:
220+
return None
221+
return {
222+
"category": "vulnerabilities",
223+
"total": total,
224+
"top_items": vuln.get("vulnerabilities", [])[:max_items],
225+
}
226+
"""
227+
ws = self._create_workspace(code, "app.py")
228+
try:
229+
result = detect_dead_code(ws)
230+
assert result["status"] == "ok"
231+
unreachable = result.get("results", {}).get("unreachable", [])
232+
assert len(unreachable) == 0, \
233+
f"False positive on multi-line dict return: {unreachable}"
234+
finally:
235+
shutil.rmtree(ws, ignore_errors=True)
236+
237+
def test_issue_105_chained_early_returns(self):
238+
"""Multiple chained early returns + final return should NOT be flagged."""
239+
code = """def g(x):
240+
if x is None:
241+
return None
242+
if x < 0:
243+
return -1
244+
if x > 100:
245+
return 100
246+
return x
247+
"""
248+
ws = self._create_workspace(code, "app.py")
249+
try:
250+
result = detect_dead_code(ws)
251+
assert result["status"] == "ok"
252+
unreachable = result.get("results", {}).get("unreachable", [])
253+
assert len(unreachable) == 0, \
254+
f"False positive on chained early returns: {unreachable}"
255+
finally:
256+
shutil.rmtree(ws, ignore_errors=True)
257+
258+
def test_issue_105_nested_if_early_return(self):
259+
"""Nested if/return + outer returns should NOT be flagged."""
260+
code = """def m(x, y):
261+
if x:
262+
if y:
263+
return None
264+
return 1
265+
return 2
266+
"""
267+
ws = self._create_workspace(code, "app.py")
268+
try:
269+
result = detect_dead_code(ws)
270+
assert result["status"] == "ok"
271+
unreachable = result.get("results", {}).get("unreachable", [])
272+
assert len(unreachable) == 0, \
273+
f"False positive on nested if early return: {unreachable}"
274+
finally:
275+
shutil.rmtree(ws, ignore_errors=True)
276+
277+
def test_issue_105_genuinely_unreachable_still_detected(self):
278+
"""Sanity check: genuinely unreachable code after unconditional
279+
return must still be detected after the fix."""
280+
code = """def f():
281+
return None
282+
print("unreachable")
283+
"""
284+
ws = self._create_workspace(code, "app.py")
285+
try:
286+
result = detect_dead_code(ws)
287+
assert result["status"] == "ok"
288+
unreachable = result.get("results", {}).get("unreachable", [])
289+
assert len(unreachable) >= 1, \
290+
f"Regression: genuinely unreachable code not detected: {unreachable}"
291+
finally:
292+
shutil.rmtree(ws, ignore_errors=True)

0 commit comments

Comments
 (0)