Skip to content

fix(complexity): add cognitive/cyclomatic/Halstead support for c/cpp/kotlin/swift/scala/bash#2059

Open
carlos-alm wants to merge 1 commit into
fix/issue-1922-wasm-complexity-engine-skips-entire-file-whenfrom
fix/issue-1923-complexity-metrics-cognitive-cyclomatic
Open

fix(complexity): add cognitive/cyclomatic/Halstead support for c/cpp/kotlin/swift/scala/bash#2059
carlos-alm wants to merge 1 commit into
fix/issue-1922-wasm-complexity-engine-skips-entire-file-whenfrom
fix/issue-1923-complexity-metrics-cognitive-cyclomatic

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

Ports the "tier-1" languages identified in #1923 — c, cpp, kotlin, swift, scala, bash already had a native LangRules/HalsteadRules definition in crates/codegraph-core/src/ast_analysis/complexity.rs, but the WASM/TS side had no COMPLEXITY_RULES/HALSTEAD_RULES entry at all, so codegraph complexity silently returned functions: [] for these languages under the WASM engine.

Added complexity/complexityCpp + halstead/halsteadCpp to src/ast-analysis/rules/c.ts, complexityKotlin/complexitySwift/complexityScala (+ Halstead) to b2.ts, complexityBash/halsteadBash to b3.ts, and wired all of them into COMPLEXITY_RULES/HALSTEAD_RULES in index.ts. Also added the matching LOC comment-prefix entries in ast-analysis/metrics.ts.

Rather than trust the native config as pre-verified, every rule was checked against real tree-sitter output for these six languages (not just read from the Rust source), which surfaced three real, previously-shipping bugs in the native engine — fixed in this PR on both engines since I was already introducing this exact code path to WASM and wasn't going to knowingly port broken behavior into a brand-new integration:

  • C_RULES/CPP_RULES had else_via_alternative backwards. tree-sitter-c/cpp wrap the else branch in a real else_clause node (Pattern A, like JS/C#/Rust) — confirmed by parsing and inspecting the S-expression — not Go/Java's alternative-field pattern the rules assumed. A canonical if {} else if {} else {} computed cognitive: 5, maxNesting: 2 instead of the correct cognitive: 3, maxNesting: 1 (same as every other language for the identical shape). Fixed else_node_type/else_via_alternative and added else_clause to branch_nodes.
  • BASH_RULES was missing else_clause from branch_nodes. else_node_type/elif_node_type were already correct (flat-sibling pattern, same as Python/Ruby/PHP/Lua), but branch_nodes only listed elif_clause — a trailing plain else got zero cognitive credit. if/elif/else computed cognitive: 2 instead of 3.
  • SWIFT_RULES used logical_node_types: ["binary_expression"], but Swift splits &&/|| into conjunction_expression/disjunction_expression (same as Kotlin) — confirmed by parsing a && b || a. Swift's &&/|| were never counted as logical operators at all under the existing config.

Also found and fixed a WASM-only divergence from the Rust engine (not a native bug): computeFunctionComplexity (features/complexity.ts) and the complexity visitor (ast-analysis/visitors/complexity-visitor.ts) evaluated branchNodes and caseNodes independently, so a node type listed in both sets (Kotlin's when_entry, Scala's case_clause — both real, if imperfect, entries already in the native rules) got double-counted. Rust's walk() returns unconditionally after a branch_nodes match, making the case_nodes check unreachable for such a type — a when/match with 3 cases computed cyclomatic 7 in WASM vs 4 in native, an immediately obvious cross-engine divergence. Fixed both TS implementations to mirror Rust's exclusivity.

Widened ComplexityRules.logicalNodeType (singular string | null) to logicalNodeTypes: Set<string>, matching the native logical_node_types: &'static [&'static str] slice shape — the old singular field could never represent Kotlin/Swift's split &&/|| node types. Updated all 9 existing per-language rule files to the plural field name (mechanical rename, no behavior change for any of them since they all use one shared binary-op node type).

Deferred (filed, not fixed here)

Filed #2058 for remaining known-imperfect native rules that this PR intentionally mirrors as-is on the TS side for engine parity — these are pre-existing native behavior, not something this PR introduces, and need methodology judgment calls rather than a mechanical config fix:

  • Case-node shadowing for C/C++/Kotlin/Scala's switch/match (case_statement/when_entry/case_clause listed in both branch_nodes and case_nodes, so each case gets nesting-weighted cognitive credit like a fresh branch instead of a flat +1).
  • Swift's switch container missing entirely from branch_nodes/nesting_nodes.
  • Kotlin/Swift's else if chains counting as progressively nested branches instead of a flat chain — verified their grammars have no else_clause wrapper and no alternative field (unlike C/C++, which I could fix, and Scala, which already has a real alternative field). Needs a new sibling-position detection primitive in the shared classify_branch/detect_else_if algorithm, not a data fix.
  • c/cpp/kotlin/swift/scala's comment_prefixes() list (["//", "/*"]) missing */*/ continuation-line prefixes, undercounting multi-line block comments for the MI calculation.

The remaining 17 tier-2 languages (functional/pattern-matching languages needing from-scratch grammar design per #1923's own scoping — Haskell, OCaml, F#, Clojure, Erlang, Gleam, etc.) are left for follow-up work under #1923, which stays open.

Refs #1923

Test plan

  • npm test — full suite green (255 files, 4061 passed, 30 skipped, 2 todo, 0 failed)
  • cargo test --release (crates/codegraph-core) — 624 passed, including 17 new tests covering c/cpp/kotlin/swift/scala/bash complexity + the else-clause/else_via_alternative fixes
  • npm run lint / npm run build — clean
  • New tests in tests/unit/complexity.test.ts: per-language suites for C/C++/Kotlin/Swift/Scala/Bash (simple function, single if, if/else-if/else where applicable, logical operators, switch/when/match, Halstead volume, LOC comments), plus a DFS-vs-visitor parity suite covering all six languages including Kotlin's multi-node-type logical operator case
  • Native addon rebuilt (napi build --platform --release), codesigned, and verified directly: codegraph complexity --file <fixture> --engine native vs --engine wasm produce byte-identical JSON for real fixture files in all six languages (tests/benchmarks/resolution/fixtures/{c,cpp,kotlin,swift,scala,bash}/service.*)

…kotlin/swift/scala/bash

Ports the tier-1 languages from issue #1923 (native rules already existed,
WASM/TS had no COMPLEXITY_RULES/HALSTEAD_RULES entries at all) into
src/ast-analysis/rules/{c,b2,b3}.ts and wires them into COMPLEXITY_RULES/
HALSTEAD_RULES. Verified every rule against real tree-sitter output rather
than assuming the native config was correct, which surfaced and fixed three
real bugs affecting both engines:

- C/CPP_RULES had else_via_alternative backwards: tree-sitter-c/cpp wrap the
  else branch in a real else_clause node (Pattern A, like JS/C#/Rust), not
  Go/Java's alternative-field pattern. A canonical if/else-if/else computed
  cognitive=5, maxNesting=2 instead of the correct cognitive=3, maxNesting=1.
- BASH_RULES was missing else_clause from branch_nodes, so a trailing plain
  else got zero cognitive credit despite else_node_type being configured.
- SWIFT_RULES used logical_node_types: ["binary_expression"], but Swift (like
  Kotlin) splits && / || into conjunction_expression/disjunction_expression —
  so Swift's && / || were never counted at all.

Also fixed a WASM-only divergence from the Rust engine: computeFunctionComplexity
and the complexity visitor evaluated branchNodes and caseNodes independently,
double-counting any node type listed in both sets (Kotlin's when_entry, Scala's
case_clause). Rust's walk() returns unconditionally after a branch-node match,
making case handling unreachable for such types; TS now mirrors that exclusivity.

Widened ComplexityRules.logicalNodeType (singular) to logicalNodeTypes (Set),
matching the native logical_node_types slice, so Kotlin/Swift's split && / ||
node types can both be registered — the old singular field could only ever
hold one type.

Filed #2058 for the remaining known-imperfect native rules mirrored as-is for
engine parity: case-node shadowing for C/C++/Kotlin/Scala's switch/match
(inflates cognitive complexity per case), Swift's switch container missing
entirely from branch_nodes, Kotlin/Swift's else-if chains counting as
progressively nested branches instead of a flat chain (needs a new
sibling-position detection primitive, not a config fix), and c/cpp/kotlin/
swift/scala's comment-prefix list undercounting multi-line block comments.

The remaining 17 tier-2 languages (functional/pattern-matching languages
needing from-scratch grammar design, per #1923's own scoping) are left for
follow-up work under #1923, which stays open.
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

5 functions changed11 callers affected across 2 files

  • classifyLogicalOp in src/ast-analysis/visitors/complexity-visitor.ts:116 (2 transitive callers)
  • classifyNode in src/ast-analysis/visitors/complexity-visitor.ts:186 (1 transitive callers)
  • handleLogicalOperator in src/features/complexity.ts:145 (9 transitive callers)
  • handleBranchNode in src/features/complexity.ts:234 (9 transitive callers)
  • ComplexityRules.logicalNodeTypes in src/types.ts:993 (0 transitive callers)

@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR ports six tier-1 languages (C, C++, Kotlin, Swift, Scala, Bash) to the WASM complexity engine by adding COMPLEXITY_RULES/HALSTEAD_RULES entries that were already defined in the Rust native engine but missing on the TypeScript side. Along the way it fixes three real bugs found in the native rules and one WASM-only divergence.

  • Three native bug fixes applied to both engines: C/C++ else_via_alternative was backwards (else branches miscounted), Bash else_clause was absent from branch_nodes (plain else got zero cognitive credit), and Swift logical_node_types used binary_expression instead of conjunction_expression/disjunction_expression (&&/|| were never counted).
  • WASM double-count fix: handleBranchNode in the DFS path and the isBranchNode guard in the visitor now prevent a node type in both branchNodes and caseNodes (e.g. Kotlin's when_entry, Scala's case_clause) from being counted twice — matching Rust's unconditional early return after classify_branch.
  • Schema change: ComplexityRules.logicalNodeType: string | null renamed to logicalNodeTypes: Set<string> to support Kotlin/Swift's split conjunction_expression/disjunction_expression node types; all nine existing language rule files updated mechanically.

Confidence Score: 4/5

Safe to merge — all three native bugs are verified against real tree-sitter output and the native Rust and WASM engines now produce byte-identical JSON for fixture files in all six languages.

The core algorithm changes (isBranchNode guard, handleBranchNode always returning true) are correct and fully covered by new DFS-vs-visitor parity tests. One low-risk structural oddity remains: switch_statement appears in switchLikeNodes for C/C++ but not in branchNodes, making the switchLikeNodes entry unreachable at runtime — an exact mirror of the native config, intentionally deferred to #2058. No data loss or incorrect metric values introduced.

src/ast-analysis/rules/c.ts — the switch_statement/switchLikeNodes dead-config detail is worth a second glance, though it is intentional parity with the native engine.

Important Files Changed

Filename Overview
src/features/complexity.ts handleBranchNode now returns true (and explicitly walks children) for any matched branch node, preventing the caller's caseNodes check from double-counting cyclomatic; also updated to use logicalNodeTypes Set
src/ast-analysis/visitors/complexity-visitor.ts Added isBranchNode guard so the caseNodes cyclomatic increment is skipped for nodes already handled as branch nodes, mirroring Rust's early-return behavior; logicalNodeType → logicalNodeTypes updated
src/ast-analysis/rules/c.ts Adds complexity/halstead rules for C and C++; correctly uses Pattern A (else_clause wrapper, elseViaAlternative: false); switch_statement appears in switchLikeNodes but not branchNodes, making the switchLikeNodes entry unreachable
src/ast-analysis/rules/b2.ts Adds complexity/halstead rules for Kotlin, Swift, and Scala; correctly uses split logicalNodeTypes for Kotlin/Swift; known-imperfect when_entry/case_clause dual-set membership is intentional parity with native (#2058)
src/ast-analysis/rules/b3.ts Adds complexity/halstead rules for Bash; mechanically renames logicalNodeType → logicalNodeTypes for Lua; else_clause now correctly included in branchNodes
src/types.ts Widens ComplexityRules.logicalNodeType (string
crates/codegraph-core/src/ast_analysis/complexity.rs Fixes three native bugs (C/C++ else_via_alternative, Bash missing else_clause, Swift wrong logical_node_types) and adds 17 new Rust tests covering all six languages
tests/unit/complexity.test.ts Adds per-language test suites for C/C++/Kotlin/Swift/Scala/Bash plus DFS-vs-visitor parity tests for all six; updates language-count assertions from 11 to 17
src/ast-analysis/metrics.ts Registers comment prefixes for the six new languages; C_LIKE_PREFIXES intentionally uses only ['//','/'] to match native engine (missing ''/'*/' acknowledged as a known limitation in #2058)
src/ast-analysis/rules/index.ts Wires all six new languages into COMPLEXITY_RULES and HALSTEAD_RULES maps; insertion order matches the existing pattern

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Node visited] --> B{in branchNodes\nAND childCount > 0?}
    B -- No --> C{in caseNodes\nAND childCount > 0?}
    B -- Yes\nisBranchNode=true --> D{is elseNodeType?}
    C -- Yes --> E[cyclomatic++]
    C -- No --> F[walk children\nnormally]
    D -- Yes --> G{first namedChild\nis ifNodeType?}
    G -- Yes, else-if --> H[walk children\nat same level]
    G -- No, plain else --> I[cognitive++\nwalk children]
    D -- No --> J{is elifNodeType?}
    J -- Yes --> K[cognitive++\ncyclomatic++\nwalk children]
    J -- No --> L{is ifNodeType AND\nisElseIf pattern?}
    L -- Yes --> M[cognitive++\ncyclomatic++\nwalk children]
    L -- No, regular branch --> N[cognitive += 1+nesting\ncyclomatic++]
    N --> O{in switchLikeNodes?}
    O -- Yes --> P[cyclomatic--\nnet 0 for container]
    O -- No --> Q{in nestingNodes?}
    P --> Q
    Q -- Yes --> R[walk children\nat nesting+1]
    Q -- No --> S[walk children\nat same level]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Node visited] --> B{in branchNodes\nAND childCount > 0?}
    B -- No --> C{in caseNodes\nAND childCount > 0?}
    B -- Yes\nisBranchNode=true --> D{is elseNodeType?}
    C -- Yes --> E[cyclomatic++]
    C -- No --> F[walk children\nnormally]
    D -- Yes --> G{first namedChild\nis ifNodeType?}
    G -- Yes, else-if --> H[walk children\nat same level]
    G -- No, plain else --> I[cognitive++\nwalk children]
    D -- No --> J{is elifNodeType?}
    J -- Yes --> K[cognitive++\ncyclomatic++\nwalk children]
    J -- No --> L{is ifNodeType AND\nisElseIf pattern?}
    L -- Yes --> M[cognitive++\ncyclomatic++\nwalk children]
    L -- No, regular branch --> N[cognitive += 1+nesting\ncyclomatic++]
    N --> O{in switchLikeNodes?}
    O -- Yes --> P[cyclomatic--\nnet 0 for container]
    O -- No --> Q{in nestingNodes?}
    P --> Q
    Q -- Yes --> R[walk children\nat nesting+1]
    Q -- No --> S[walk children\nat same level]
Loading

Comments Outside Diff (1)

  1. src/features/complexity.ts, line 256-268 (link)

    P2 handleBranchNode now silently walks children for non-nesting branch nodes — consider an explicit comment about the handlePatternCElse skip

    When handleBranchNode returns true for a node that is in branchNodes but not in nestingNodes (e.g. case_statement in C, when_entry in Kotlin), the caller's handlePatternCElse call is skipped via the early return. This is safe today because no language has a node type that could simultaneously satisfy branchNodes and the elseViaAlternative path of handlePatternCElse (that path requires the node to be the alternative field of a parent ifNodeType, which would never be a case/when_entry). But the invariant is implicit — a short inline note (e.g. // handlePatternCElse is unreachable for nodes in branchNodes) would make the assumption explicit for the next person who adds a new language.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(complexity): add cognitive/cyclomati..." | Re-trigger Greptile

'while_statement',
'do_statement',
'case_statement',
'conditional_expression',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 switch_statement in switchLikeNodes is unreachable for C/C++

switchLikeNodes is only consulted inside handleBranchNode / classifyBranchNode, both of which gate on branchNodes.has(type) first. Because switch_statement is not in branchNodes for C or C++, the cyclomatic-decrement logic (acc.cyclomatic--) can never fire for it in either code path. The entry is therefore dead config in the TS engine (and mirrors the same structural quirk in the native C_RULES/CPP_RULES). This is acknowledged as intentional parity pending #2058 — just flagging so a future reader doesn't wonder why adding switch_statement to branchNodes suddenly makes the switchLikeNodes entry "activate".

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant