Skip to content

Commit 8be80db

Browse files
feat: add cyclomatic complexity calculation and update response model (imDarshanGK#185)
Co-authored-by: Darshan G K <122042809+imDarshanGK@users.noreply.github.com>
1 parent fac3faa commit 8be80db

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

backend/app/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class ExplanationResponse(BaseModel):
2828
line_count: int
2929
function_count: int
3030
class_count: int
31+
cyclomatic_complexity: int
32+
complexity_risk: str
3133

3234

3335
# ── Debugging ─────────────────────────────────────────────────────────────────

backend/app/services/code_assistant.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,41 @@ def detect_language(code: str, hint: str | None = None) -> str:
9595
return best if scores[best] > 0 else "Unknown"
9696

9797

98+
# ── Cyclomatic Complexity ──────────────────────────────────────────────────────
99+
_DECISION_RE = re.compile(
100+
r"\b(if|elif|else|for|while|and|or|case|catch|except)\b|\?(?![?:.])",
101+
re.MULTILINE,
102+
)
103+
104+
_RISK_THRESHOLDS: tuple[tuple[int, str], ...] = (
105+
(5, "Simple"),
106+
(10, "Moderate"),
107+
(20, "High"),
108+
)
109+
110+
111+
def calculate_cyclomatic_complexity(code: str, language: str) -> tuple[int, str]:
112+
"""Calculate the cyclomatic complexity of a code snippet.
113+
114+
Uses a simplified McCabe formula: M = decision points + 1, where decision
115+
points are control-flow keywords (if, elif, else, for, while, and, or,
116+
case, catch, except) and ternary operators.
117+
118+
Args:
119+
code: The source code to analyse.
120+
language: The programming language of the code.
121+
122+
Returns:
123+
A tuple of (score, risk) where risk is one of "Simple", "Moderate",
124+
"High", or "Very High".
125+
"""
126+
score = len(_DECISION_RE.findall(code)) + 1
127+
for threshold, label in _RISK_THRESHOLDS:
128+
if score <= threshold:
129+
return score, label
130+
return score, "Very High"
131+
132+
98133
# ── Complexity Estimation ──────────────────────────────────────────────────────
99134
def estimate_complexity(code: str) -> str:
100135
"""Estimate the overall complexity level of the given code snippet.
@@ -599,6 +634,7 @@ def run_explanation(code: str, language: str) -> dict:
599634
lines = code.splitlines()
600635
non_blank = [line for line in lines if line.strip()]
601636
complexity = estimate_complexity(code)
637+
cyclomatic_complexity, complexity_risk = calculate_cyclomatic_complexity(code, language)
602638

603639
func_names = re.findall(
604640
r"def\s+(\w+)\s*\(|function\s+(\w+)\s*\(|(\w+)\s*=\s*\(.*\)\s*=>|\bfn\s+(\w+)\s*\(",
@@ -650,6 +686,8 @@ def run_explanation(code: str, language: str) -> dict:
650686
"line_count": len(lines),
651687
"function_count": len(funcs),
652688
"class_count": len(class_names),
689+
"cyclomatic_complexity": cyclomatic_complexity,
690+
"complexity_risk": complexity_risk,
653691
}
654692

655693

backend/tests/test_endpoints.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,124 @@ def test_explanation_cpp():
242242
d = r.json()
243243
assert d["language"] == "C++"
244244

245+
# ── Cyclomatic Complexity ─────────────────────────────────────────────────────
246+
def test_explanation_cyclomatic_fields_present():
247+
r = client.post("/explanation/", json={"code": PYTHON_CLEAN, "language": "python"})
248+
assert r.status_code == 200
249+
d = r.json()
250+
assert "cyclomatic_complexity" in d
251+
assert "complexity_risk" in d
252+
assert isinstance(d["cyclomatic_complexity"], int)
253+
assert d["complexity_risk"] in ("Simple", "Moderate", "High", "Very High")
254+
255+
256+
def test_explanation_cyclomatic_simple():
257+
code = "def add(a: int, b: int) -> int:\n return a + b\n"
258+
r = client.post("/explanation/", json={"code": code, "language": "python"})
259+
assert r.status_code == 200
260+
d = r.json()
261+
assert d["cyclomatic_complexity"] >= 1
262+
assert d["complexity_risk"] == "Simple"
263+
264+
265+
def test_explanation_cyclomatic_moderate():
266+
code = """
267+
def validate(x, y, z):
268+
if x < 0:
269+
return False
270+
elif y < 0:
271+
return False
272+
elif z < 0:
273+
return False
274+
elif x > 100 or y > 100:
275+
return False
276+
else:
277+
return True
278+
"""
279+
r = client.post("/explanation/", json={"code": code, "language": "python"})
280+
assert r.status_code == 200
281+
d = r.json()
282+
assert 6 <= d["cyclomatic_complexity"] <= 10
283+
assert d["complexity_risk"] == "Moderate"
284+
285+
286+
def test_explanation_cyclomatic_high():
287+
code = """
288+
def process(items, config, flags):
289+
if not items:
290+
return []
291+
elif not config:
292+
return None
293+
results = []
294+
for item in items:
295+
if item and flags.get("enabled"):
296+
if item > 0 and item < 100:
297+
results.append(item)
298+
elif item >= 100 or item == -1:
299+
results.append(item * 2)
300+
else:
301+
results.append(0)
302+
elif item is None:
303+
pass
304+
else:
305+
while item > 0:
306+
item -= 1
307+
results.append(item)
308+
return results
309+
"""
310+
r = client.post("/explanation/", json={"code": code, "language": "python"})
311+
assert r.status_code == 200
312+
d = r.json()
313+
assert 11 <= d["cyclomatic_complexity"] <= 20
314+
assert d["complexity_risk"] == "High"
315+
316+
317+
def test_explanation_cyclomatic_very_high():
318+
code = """
319+
def route(req, user, db, cache, logger):
320+
if not req:
321+
return None
322+
elif not user:
323+
return None
324+
elif not db:
325+
return None
326+
if user.role == "admin" and user.active and not user.banned:
327+
if req.method == "GET" or req.method == "HEAD":
328+
if cache.has(req.path) and not req.bypass_cache:
329+
return cache.get(req.path)
330+
else:
331+
result = db.query(req.path)
332+
if result and not result.expired:
333+
cache.set(req.path, result)
334+
return result
335+
elif result and result.expired:
336+
if cache.has_stale(req.path) or logger.warn("stale"):
337+
return cache.get_stale(req.path)
338+
else:
339+
return None
340+
else:
341+
return None
342+
elif req.method == "POST":
343+
if user.can_write and req.body:
344+
for item in req.body:
345+
if item.valid and item.size < 1024:
346+
db.insert(item)
347+
else:
348+
logger.warn("invalid item")
349+
else:
350+
return None
351+
else:
352+
return None
353+
else:
354+
return None
355+
"""
356+
r = client.post("/explanation/", json={"code": code, "language": "python"})
357+
assert r.status_code == 200
358+
d = r.json()
359+
assert d["cyclomatic_complexity"] >= 21
360+
assert d["complexity_risk"] == "Very High"
361+
362+
245363
# ── Debugging ─────────────────────────────────────────────────────────────────
246364
def test_debug_detects_zero_division():
247365
r = client.post("/debugging/", json={"code": "result = a / b", "language": "python"})

frontend/index.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,30 @@
11181118
border: 1px solid var(--red);
11191119
}
11201120

1121+
.risk-Simple {
1122+
background: rgba(34, 197, 94, 0.12);
1123+
color: var(--green);
1124+
border: 1px solid var(--green);
1125+
}
1126+
1127+
.risk-Moderate {
1128+
background: rgba(234, 179, 8, 0.12);
1129+
color: var(--yellow);
1130+
border: 1px solid var(--yellow);
1131+
}
1132+
1133+
.risk-High {
1134+
background: rgba(249, 115, 22, 0.12);
1135+
color: var(--orange);
1136+
border: 1px solid var(--orange);
1137+
}
1138+
1139+
.risk-Very-High {
1140+
background: rgba(239, 68, 68, 0.12);
1141+
color: var(--red);
1142+
border: 1px solid var(--red);
1143+
}
1144+
11211145
.key-points {
11221146
list-style: none
11231147
}
@@ -2912,6 +2936,7 @@ <h3>No suggestions yet</h3>
29122936
<div class="meta-chip"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/></svg> ${exp.line_count} lines</div>
29132937
<div class="meta-chip"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16,18 22,12 16,6"/><polyline points="8,6 2,12 8,18"/></svg> ${exp.function_count} fn${exp.function_count !== 1 ? 's' : ''}</div>
29142938
${exp.class_count > 0 ? '<div class="meta-chip"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/></svg> ' + exp.class_count + ' class' + (exp.class_count !== 1 ? 'es' : '') + '</div>' : ''}
2939+
${exp.cyclomatic_complexity != null ? `<div class="meta-chip risk-${(exp.complexity_risk || 'Simple').replace(/\s+/g, '-')}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg> M=${exp.cyclomatic_complexity} &middot; ${exp.complexity_risk}</div>` : ''}
29152940
</div>
29162941
<ul class="key-points">
29172942
${exp.key_points.map((p, i) => `<li style="animation-delay:${i * 0.05}s">${renderMarkdown(p)}</li>`).join('')}

0 commit comments

Comments
 (0)