|
24 | 24 | - `friend` keyword exposing internals: -1 per occurrence (cap 5) |
25 | 25 | - unpaired `new` without matching `delete`: -1 per occurrence (cap 5) |
26 | 26 | - local variable shadowing a member variable: -1 per occurrence (cap 5) |
27 | | - - file longer than 500 lines: -1 per additional 50-line block |
| 27 | + - file longer than 500 lines: -2 per additional 50-line block |
28 | 28 | - each `new` keyword usage: -1 per occurrence (no cap) |
29 | 29 | - function with more than 7 parameters: -1 per extra param (cap 30 per file) |
| 30 | + - function cyclomatic complexity > 10 (if/for/while/switch/case/&&/||): |
| 31 | + -1 per extra point (cap 30 per file) |
30 | 32 |
|
31 | 33 | Usage: |
32 | 34 | python3 code_quality_score.py source/source_base |
|
88 | 90 | "friend_keyword": 1, |
89 | 91 | "unpaired_new_delete": 1, |
90 | 92 | "member_local_name_conflict": 1, |
91 | | - "file_too_long": 1, |
| 93 | + "file_too_long": 2, |
92 | 94 | "raw_new_keyword": 1, |
93 | 95 | "too_many_parameters": 1, |
| 96 | + "high_cyclomatic_complexity": 1, |
94 | 97 | } |
95 | 98 |
|
96 | 99 | CAPS = { |
|
106 | 109 | "unpaired_new_delete": 5, |
107 | 110 | "member_local_name_conflict": 5, |
108 | 111 | "too_many_parameters": 30, |
| 112 | + "high_cyclomatic_complexity": 30, |
109 | 113 | } |
110 | 114 |
|
111 | 115 | FUNCTION_LENGTH_THRESHOLD = 50 |
112 | 116 | FUNCTION_LENGTH_STEP = 50 |
113 | 117 | FILE_LENGTH_THRESHOLD = 500 |
114 | 118 | FILE_LENGTH_STEP = 50 |
115 | 119 | FUNCTION_PARAM_THRESHOLD = 7 |
| 120 | +CYCLO_THRESHOLD = 10 |
116 | 121 | LINE_LENGTH_LIMIT = 120 |
117 | 122 | FILENAME_LENGTH_LIMIT = 20 |
118 | 123 | PASS_THRESHOLD = 60 |
|
159 | 164 | } |
160 | 165 | FUNC_NAME_RE = re.compile(r"\b([A-Za-z_]\w*)\s*\(") |
161 | 166 | QUALIFIER_RE = re.compile(r"\b(?:const|override|final|noexcept)\b") |
| 167 | +CYCLO_KEYWORDS_RE = re.compile(r"\b(?:if|for|while|switch|case)\b|&&|\|\|") |
162 | 168 |
|
163 | 169 |
|
164 | 170 | @dataclass |
@@ -521,6 +527,123 @@ def find_long_function_signatures( |
521 | 527 | return findings |
522 | 528 |
|
523 | 529 |
|
| 530 | +def find_function_bodies(content: str) -> List[Tuple[int, str, int, int]]: |
| 531 | + """Find function definitions (with body), not just declarations. |
| 532 | +
|
| 533 | + Returns list of (signature_line_no, function_name, body_start_pos, |
| 534 | + body_end_pos) where positions are absolute offsets in stripped content. |
| 535 | + Reuses the prefix/reject logic from find_long_function_signatures. |
| 536 | + """ |
| 537 | + stripped = strip_comments(content) |
| 538 | + n = len(stripped) |
| 539 | + bodies: List[Tuple[int, str, int, int]] = [] |
| 540 | + |
| 541 | + i = 0 |
| 542 | + while i < n: |
| 543 | + m = FUNC_NAME_RE.search(stripped, i) |
| 544 | + if not m: |
| 545 | + break |
| 546 | + name = m.group(1) |
| 547 | + if name in NON_FUNCTION_KEYWORDS: |
| 548 | + i = m.end() |
| 549 | + continue |
| 550 | + |
| 551 | + line_start = stripped.rfind("\n", 0, m.start()) + 1 |
| 552 | + prefix = stripped[line_start:m.start()] |
| 553 | + prefix_stripped = prefix.rstrip() |
| 554 | + prefix_lstripped = prefix.lstrip() |
| 555 | + |
| 556 | + if prefix_lstripped.startswith("#"): |
| 557 | + i = m.end() |
| 558 | + continue |
| 559 | + if prefix_stripped.endswith("]"): |
| 560 | + i = m.end() |
| 561 | + continue |
| 562 | + if prefix_stripped.endswith(".") or prefix_stripped.endswith("->"): |
| 563 | + i = m.end() |
| 564 | + continue |
| 565 | + if prefix_stripped.endswith("=") and not prefix_stripped.endswith("=="): |
| 566 | + i = m.end() |
| 567 | + continue |
| 568 | + if "typedef" in prefix_stripped: |
| 569 | + i = m.end() |
| 570 | + continue |
| 571 | + |
| 572 | + paren_open = m.end() - 1 |
| 573 | + depth = 1 |
| 574 | + j = paren_open + 1 |
| 575 | + while j < n and depth > 0: |
| 576 | + c = stripped[j] |
| 577 | + if c == "(": |
| 578 | + depth += 1 |
| 579 | + elif c == ")": |
| 580 | + depth -= 1 |
| 581 | + if depth == 0: |
| 582 | + break |
| 583 | + j += 1 |
| 584 | + if depth != 0: |
| 585 | + i = m.end() |
| 586 | + continue |
| 587 | + |
| 588 | + # find what comes after `)`: skip whitespace and qualifiers |
| 589 | + pos = j + 1 |
| 590 | + while pos < n and stripped[pos] in " \t\n": |
| 591 | + pos += 1 |
| 592 | + while True: |
| 593 | + mq = QUALIFIER_RE.match(stripped, pos) |
| 594 | + if not mq: |
| 595 | + break |
| 596 | + pos = mq.end() |
| 597 | + while pos < n and stripped[pos] in " \t\n": |
| 598 | + pos += 1 |
| 599 | + |
| 600 | + # we need a `{` body (not `;` declaration, not `= 0` pure virtual) |
| 601 | + if pos >= n or stripped[pos] != "{": |
| 602 | + i = j + 1 |
| 603 | + continue |
| 604 | + |
| 605 | + # match braces to find body end |
| 606 | + body_open = pos |
| 607 | + depth = 1 |
| 608 | + body_close = body_open + 1 |
| 609 | + while body_close < n and depth > 0: |
| 610 | + c = stripped[body_close] |
| 611 | + if c == "{": |
| 612 | + depth += 1 |
| 613 | + elif c == "}": |
| 614 | + depth -= 1 |
| 615 | + if depth == 0: |
| 616 | + break |
| 617 | + body_close += 1 |
| 618 | + if depth != 0: |
| 619 | + i = j + 1 |
| 620 | + continue |
| 621 | + |
| 622 | + sig_line_no = stripped[:paren_open].count("\n") + 1 |
| 623 | + bodies.append((sig_line_no, name, body_open, body_close)) |
| 624 | + i = body_close + 1 |
| 625 | + |
| 626 | + return bodies |
| 627 | + |
| 628 | + |
| 629 | +def find_high_complexity_functions( |
| 630 | + content: str, threshold: int |
| 631 | +) -> List[Tuple[int, str, int]]: |
| 632 | + """Find functions whose cyclomatic complexity exceeds threshold. |
| 633 | +
|
| 634 | + Cyclomatic complexity counts: if, for, while, switch, case, &&, ||. |
| 635 | + Returns list of (line_no, function_name, complexity). |
| 636 | + """ |
| 637 | + stripped = strip_comments(content) |
| 638 | + findings: List[Tuple[int, str, int]] = [] |
| 639 | + for sig_line, name, body_open, body_close in find_function_bodies(content): |
| 640 | + body = stripped[body_open + 1:body_close] |
| 641 | + complexity = len(CYCLO_KEYWORDS_RE.findall(body)) |
| 642 | + if complexity > threshold: |
| 643 | + findings.append((sig_line, name, complexity)) |
| 644 | + return findings |
| 645 | + |
| 646 | + |
524 | 647 | def analyze_class_blocks(content: str) -> Tuple[List[Finding], List[Finding], List[Finding]]: |
525 | 648 | """Analyze class/struct blocks for public member variables, long member |
526 | 649 | functions, and member/local name conflicts. |
@@ -762,6 +885,28 @@ def append_capped(rule: str, count: int) -> None: |
762 | 885 | deduction=per_deduction, |
763 | 886 | )) |
764 | 887 |
|
| 888 | + # high cyclomatic complexity rule (per-function, capped across the file) |
| 889 | + high_cyclo_funcs = find_high_complexity_functions(content, CYCLO_THRESHOLD) |
| 890 | + cap_cyclo = CAPS.get("high_cyclomatic_complexity") |
| 891 | + running_cyclo_deduction = 0 |
| 892 | + for line_no, fname, cyclo in high_cyclo_funcs: |
| 893 | + excess = cyclo - CYCLO_THRESHOLD |
| 894 | + per_deduction = excess * WEIGHTS["high_cyclomatic_complexity"] |
| 895 | + if cap_cyclo is not None and running_cyclo_deduction + per_deduction > cap_cyclo: |
| 896 | + per_deduction = max(0, cap_cyclo - running_cyclo_deduction) |
| 897 | + if per_deduction == 0: |
| 898 | + break |
| 899 | + running_cyclo_deduction += per_deduction |
| 900 | + findings.append(Finding( |
| 901 | + rule="high_cyclomatic_complexity", |
| 902 | + line=line_no, |
| 903 | + reason=( |
| 904 | + f"function '{fname}' has cyclomatic complexity {cyclo} " |
| 905 | + f"(exceeds {CYCLO_THRESHOLD} by {excess})" |
| 906 | + ), |
| 907 | + deduction=per_deduction, |
| 908 | + )) |
| 909 | + |
765 | 910 | # class-based rules |
766 | 911 | pub_findings, long_func_findings, conflict_findings = analyze_class_blocks(content) |
767 | 912 | findings.extend(pub_findings) |
|
0 commit comments