code-gauge 4.2.2 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -74,7 +74,7 @@ const SWITCH_LIKE_NODE_TYPES: &[&str] = &[
74
74
  "case_match",
75
75
  ];
76
76
 
77
- // Per-case decision nodes add no cognitive point because the switch itself carries the cost.
77
+ // Per-case decision nodes: cyclomatic-only, because the switch itself carries the cognitive cost.
78
78
  const CASE_CLAUSE_NODE_TYPES: &[&str] = &[
79
79
  "case_clause",
80
80
  "switch_case",
@@ -95,6 +95,7 @@ const CASE_CLAUSE_NODE_TYPES: &[&str] = &[
95
95
  const IF_LIKE_NODE_TYPES: &[&str] = &["if_statement", "if_expression", "if", "unless"];
96
96
 
97
97
  pub struct FunctionBodyMetrics {
98
+ pub cyclomatic_complexity: u64,
98
99
  pub cognitive_complexity: u64,
99
100
  pub nesting_depth: u64,
100
101
  pub ncss: u64,
@@ -102,6 +103,7 @@ pub struct FunctionBodyMetrics {
102
103
 
103
104
  /// Accumulator for one function body during measure_function_body_metrics' post-order pass.
104
105
  struct FunctionBodyFrame {
106
+ cyclomatic_complexity: u64,
105
107
  cognitive_complexity: u64,
106
108
  /// Count of `1 + nesting` cognitive increments, for re-basing on hoist into the parent frame.
107
109
  nesting_sensitive_count: u64,
@@ -117,6 +119,7 @@ struct FunctionBodyFrame {
117
119
  impl FunctionBodyFrame {
118
120
  fn new(entry_cognitive_nesting: u64, entry_structural_nesting: u64) -> Self {
119
121
  FunctionBodyFrame {
122
+ cyclomatic_complexity: 1,
120
123
  cognitive_complexity: 0,
121
124
  nesting_sensitive_count: 0,
122
125
  nesting_depth: 0,
@@ -141,8 +144,8 @@ struct FunctionBodyPass<'sets, 'code, 'source> {
141
144
  /// already-computed totals: NCSS hoists as-is; cognitive complexity re-bases the nested function's
142
145
  /// nesting-sensitive increments (each worth `1 + nesting`) by the nesting offset at the embedding
143
146
  /// site, while flat increments (else branches, boolean-operator sequences, chain continuations,
144
- /// jumps, guards) hoist unchanged; nesting depth describes the own body only, so it does not
145
- /// hoist.
147
+ /// jumps, guards) hoist unchanged; cyclomatic complexity and nesting depth describe the own body
148
+ /// only, so nothing hoists.
146
149
  pub fn measure_function_body_metrics(
147
150
  root: Node<'_>,
148
151
  sets: &LanguageSets,
@@ -186,7 +189,7 @@ impl FunctionBodyPass<'_, '_, '_> {
186
189
  }
187
190
  // The node's own increments target the frame it is embedded in, not the one it opens; a
188
191
  // frame-opening or charged-class-body node contributes nothing to that frame's own body
189
- // (nesting), matching the per-function traversal this pass replaces.
192
+ // (cyclomatic/nesting), matching the per-function traversal this pass replaces.
190
193
  let entry_cognitive_nesting = self.top_frame().entry_cognitive_nesting;
191
194
  let entry_structural_nesting = self.top_frame().entry_structural_nesting;
192
195
  let relative_nesting = current_nesting + function_nesting_bonus - entry_cognitive_nesting;
@@ -196,7 +199,7 @@ impl FunctionBodyPass<'_, '_, '_> {
196
199
  // `if` keyword token), so only named nodes count as decisions.
197
200
  let is_decision = current.is_named()
198
201
  && self.sets.decision_nodes.contains(current.kind())
199
- && !is_default_switch_branch(current);
202
+ && !is_pathless_switch_branch(current, self.code);
200
203
  let is_case_clause = current.is_named() && CASE_CLAUSE_NODE_TYPES.contains(&current.kind());
201
204
  // Ruby's `case ... else` arm is an `else` node; like every other language's default branch
202
205
  // it nests its contents inside the switch (it cannot go in the Ruby nesting set because
@@ -211,6 +214,13 @@ impl FunctionBodyPass<'_, '_, '_> {
211
214
  // surcharge (Sonar cognitive-complexity semantics).
212
215
  let is_continuation = is_decision && is_flat_chain_continuation(current);
213
216
 
217
+ // Each branch, short-circuit operator, and pattern guard adds one path (McCabe; NIST SP
218
+ // 500-235 §4); `else` adds none.
219
+ if counts_for_own_body
220
+ && (is_decision || is_boolean_operator(current, self.code) || is_pattern_guard(current))
221
+ {
222
+ self.top_frame().cyclomatic_complexity += 1;
223
+ }
214
224
  if is_decision && !is_case_clause {
215
225
  if is_continuation {
216
226
  self.top_frame().cognitive_complexity += 1;
@@ -295,6 +305,7 @@ impl FunctionBodyPass<'_, '_, '_> {
295
305
  self.results.insert(
296
306
  current.id(),
297
307
  FunctionBodyMetrics {
308
+ cyclomatic_complexity: closed.cyclomatic_complexity,
298
309
  cognitive_complexity: closed.cognitive_complexity,
299
310
  nesting_depth: closed.nesting_depth,
300
311
  // A function node without a countable declaration of its own (arrow functions,
@@ -361,7 +372,7 @@ pub fn measure_complexity(
361
372
  // `if` keyword token), so only named nodes count as decisions.
362
373
  let is_decision = current.is_named()
363
374
  && sets.decision_nodes.contains(current.kind())
364
- && !is_default_switch_branch(current);
375
+ && !is_pathless_switch_branch(current, code);
365
376
  let is_case_clause = current.is_named() && CASE_CLAUSE_NODE_TYPES.contains(&current.kind());
366
377
  // Ruby's `case ... else` arm is an `else` node; like every other language's default branch
367
378
  // it nests its contents inside the switch (it cannot go in the Ruby nesting set because
@@ -614,46 +625,138 @@ fn is_flat_chain_continuation(node: Node<'_>) -> bool {
614
625
  .is_some_and(|alternative| alternative.id() == node.id())
615
626
  }
616
627
 
617
- /// Default branches of switch-like constructs add no decision.
618
- fn is_default_switch_branch(node: Node<'_>) -> bool {
628
+ /// Switch branches that add no path. NIST SP 500-235 counts one path per case-labelled statement:
629
+ /// a label-only case shares the statement of the case below it, and that statement adds a path
630
+ /// unless one of its stacked labels is an unguarded default or catch-all (`case 3: default: g();`
631
+ /// and `default: case 3: g();` are the default outcome) or all of them are catch-alls. Guards are
632
+ /// charged separately, so a guarded catch-all never absorbs a case stacked with it.
633
+ fn is_pathless_switch_branch(node: Node<'_>, code: &Source<'_>) -> bool {
634
+ if is_label_only_case(node) {
635
+ return true;
636
+ }
637
+ let mut stacked = vec![node];
638
+ let mut previous = node.prev_named_sibling();
639
+ while let Some(sibling) = previous {
640
+ if !crate::ncss::COMMENT_NODE_TYPES.contains(&sibling.kind()) {
641
+ if !is_label_only_case(sibling) {
642
+ break;
643
+ }
644
+ stacked.push(sibling);
645
+ }
646
+ previous = sibling.prev_named_sibling();
647
+ }
648
+ stacked
649
+ .iter()
650
+ .any(|label| is_default_switch_branch(*label, code) && !has_pattern_guard(*label))
651
+ || stacked
652
+ .iter()
653
+ .all(|label| is_default_switch_branch(*label, code))
654
+ }
655
+
656
+ /// A guard directly on the arm (C# `when`, Python `if`, Ruby `if`/`unless`) or inside its label or
657
+ /// pattern (Java `when`, Rust `if`).
658
+ fn has_pattern_guard(node: Node<'_>) -> bool {
659
+ crate::util::named_children(node).into_iter().any(|child| {
660
+ is_pattern_guard(child)
661
+ || crate::util::named_children(child)
662
+ .into_iter()
663
+ .any(is_pattern_guard)
664
+ })
665
+ }
666
+
667
+ /// A C/C++/JS/Java/C# case whose labels share the next case's statements; every grammar parses each
668
+ /// stacked label as its own case node.
669
+ fn is_label_only_case(node: Node<'_>) -> bool {
670
+ let children = non_comment_children(node);
671
+ match node.kind() {
672
+ "case_statement" => {
673
+ let value = node.child_by_field_name("value").map(|value| value.id());
674
+ children.iter().all(|child| Some(child.id()) == value)
675
+ }
676
+ "switch_case" | "switch_default" => node.child_by_field_name("body").is_none(),
677
+ "switch_block_statement_group" => {
678
+ children.iter().all(|child| child.kind() == "switch_label")
679
+ }
680
+ // A C# label is a pattern (`case 1:` parses as a constant pattern) plus an optional `when`
681
+ // guard; anything else, a `#if` block included, is the section's body.
682
+ "switch_section" => children.iter().all(|child| {
683
+ child.kind().ends_with("pattern")
684
+ || child.kind() == "discard"
685
+ || child.kind() == "when_clause"
686
+ }),
687
+ _ => false,
688
+ }
689
+ }
690
+
691
+ fn is_default_switch_branch(node: Node<'_>, code: &Source<'_>) -> bool {
619
692
  let kind = node.kind();
693
+ if kind == "switch_default" {
694
+ return true;
695
+ }
620
696
  if kind == "case_statement" {
621
697
  return node.child_by_field_name("value").is_none();
622
698
  }
623
699
 
700
+ // Java `default:` groups and `default ->` rules: a label with no expression or pattern, or a
701
+ // `case null, default` label, whose `default` the grammar parses as an identifier.
624
702
  if kind == "switch_block_statement_group" || kind == "switch_rule" {
625
- let label = crate::util::named_children(node)
703
+ return non_comment_children(node)
626
704
  .into_iter()
627
- .find(|child| child.kind() == "switch_label");
628
- return label.is_some_and(|label| label.named_child_count() == 0);
705
+ .filter(|child| child.kind() == "switch_label")
706
+ .any(|label| {
707
+ let parts = non_comment_children(label);
708
+ parts.is_empty()
709
+ || parts.iter().any(|part| {
710
+ part.kind() == "identifier" && node_text(*part, code) == "default"
711
+ })
712
+ });
713
+ }
714
+
715
+ // C# `default:` sections and catch-all (`_`, `var x`) labels and arms, and Kotlin `else ->`
716
+ // entries. A guarded catch-all (`_ when cond =>`) is still a default arm: only its guard
717
+ // branches, which is_pattern_guard charges, like Python's `case _ if cond:` and Rust's
718
+ // `_ if cond =>`.
719
+ if kind == "switch_section" {
720
+ return node.child(0).is_some_and(|first| first.kind() == "default")
721
+ || crate::util::named_children(node)
722
+ .into_iter()
723
+ .any(is_csharp_catch_all_pattern);
724
+ }
725
+ if kind == "switch_expression_arm" {
726
+ return crate::util::named_children(node)
727
+ .first()
728
+ .is_some_and(|first| is_csharp_catch_all_pattern(*first));
729
+ }
730
+ if kind == "when_entry" {
731
+ return !crate::util::named_children(node)
732
+ .iter()
733
+ .any(|child| child.kind() == "when_condition");
629
734
  }
630
735
 
631
- // Python `case _:` / `case y:` and Rust `_ =>` fallback arms are unconditional like `default`.
632
- if kind == "case_clause" || kind == "match_arm" {
633
- let pattern = crate::util::named_children(node)
736
+ // Python arms with an irrefutable pattern are unconditional like `default`.
737
+ // A bare `case y, z:` or `case y,:` is a sequence pattern: its elements are direct
738
+ // case_pattern children separated by comma tokens of the clause itself.
739
+ if kind == "case_clause" {
740
+ let patterns: Vec<Node<'_>> = crate::util::named_children(node)
634
741
  .into_iter()
635
- .find(|child| child.kind() == "case_pattern" || child.kind() == "match_pattern");
636
- let Some(pattern) = pattern else {
637
- return false;
638
- };
639
- if pattern.child(0).is_some_and(|first| first.kind() == "_")
640
- && (pattern.child_count() == 1
641
- || pattern.child(1).is_some_and(|second| second.kind() == "if"))
642
- {
643
- return true;
644
- }
645
- let sole_child = if pattern.named_child_count() == 1 {
646
- pattern.named_child(0)
647
- } else {
648
- None
649
- };
650
- return kind == "case_clause"
651
- && sole_child.is_some_and(|child| {
652
- child.kind() == "dotted_name"
653
- && child.named_child_count() == 1
654
- && child
655
- .named_child(0)
656
- .is_some_and(|inner| inner.kind() == "identifier")
742
+ .filter(|child| child.kind() == "case_pattern")
743
+ .collect();
744
+ return !all_children(node).iter().any(|child| child.kind() == ",")
745
+ && matches!(patterns[..], [pattern] if is_python_irrefutable_pattern(pattern));
746
+ }
747
+ // Rust `_ =>` (optionally guarded) fallback arms.
748
+ if kind == "match_arm" {
749
+ return crate::util::named_children(node)
750
+ .into_iter()
751
+ .find(|child| child.kind() == "match_pattern")
752
+ .is_some_and(|pattern| {
753
+ // The guard keyword is anonymous, so filter all children, not just named ones.
754
+ let parts: Vec<Node<'_>> = all_children(pattern)
755
+ .into_iter()
756
+ .filter(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind()))
757
+ .collect();
758
+ matches!(parts[..], [first, ..] if first.kind() == "_")
759
+ && parts.get(1).is_none_or(|second| second.kind() == "if")
657
760
  });
658
761
  }
659
762
 
@@ -667,6 +770,64 @@ fn is_default_switch_branch(node: Node<'_>) -> bool {
667
770
  false
668
771
  }
669
772
 
773
+ /// PEP 634's irrefutable patterns: the wildcard `_`, a capture `y`, a group `(p)`, `p as y`, and
774
+ /// `p | q` when `p` (or, for `|`, any alternative) is irrefutable. A group parses as a one-element
775
+ /// tuple pattern that differs from the real tuple `(p,)` only by the comma token.
776
+ fn is_python_irrefutable_pattern(node: Node<'_>) -> bool {
777
+ match node.kind() {
778
+ "_" => true,
779
+ "case_pattern" => match non_comment_children(node)[..] {
780
+ [] => all_children(node).iter().any(|child| child.kind() == "_"),
781
+ [inner] => is_python_irrefutable_pattern(inner),
782
+ _ => false,
783
+ },
784
+ "dotted_name" => matches!(
785
+ non_comment_children(node)[..],
786
+ [name] if name.kind() == "identifier"
787
+ ),
788
+ "tuple_pattern" => {
789
+ !all_children(node).iter().any(|child| child.kind() == ",")
790
+ && matches!(
791
+ non_comment_children(node)[..],
792
+ [inner] if is_python_irrefutable_pattern(inner)
793
+ )
794
+ }
795
+ "as_pattern" => non_comment_children(node)
796
+ .first()
797
+ .is_some_and(|pattern| is_python_irrefutable_pattern(*pattern)),
798
+ "union_pattern" => all_children(node)
799
+ .into_iter()
800
+ .any(is_python_irrefutable_pattern),
801
+ _ => false,
802
+ }
803
+ }
804
+
805
+ fn non_comment_children<'t>(node: Node<'t>) -> Vec<Node<'t>> {
806
+ crate::util::named_children(node)
807
+ .into_iter()
808
+ .filter(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind()))
809
+ .collect()
810
+ }
811
+
812
+ /// C# patterns that match every value: the discard `_` and `var x`/`var _`, possibly parenthesized
813
+ /// (but not a `var (a, b)` deconstruction, which requires a deconstructible value).
814
+ fn is_csharp_catch_all_pattern(node: Node<'_>) -> bool {
815
+ if node.kind() == "parenthesized_pattern" {
816
+ return crate::util::named_children(node)
817
+ .into_iter()
818
+ .find(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind()))
819
+ .is_some_and(is_csharp_catch_all_pattern);
820
+ }
821
+ node.kind() == "discard"
822
+ || (node.kind() == "declaration_pattern"
823
+ && node
824
+ .child_by_field_name("type")
825
+ .is_some_and(|ty| ty.kind() == "implicit_type")
826
+ && !crate::util::named_children(node)
827
+ .iter()
828
+ .any(|child| child.kind() == "parenthesized_variable_designation"))
829
+ }
830
+
670
831
  /// The parent guard is required because the same tokens appear in non-boolean syntax (C++ `int&&`,
671
832
  /// `operator&&`, Rust's empty closure parameter list `|| 5`).
672
833
  fn is_boolean_operator(node: Node<'_>, code: &Source<'_>) -> bool {
@@ -3,15 +3,16 @@ use tree_sitter::Node;
3
3
 
4
4
  use crate::util::{all_children, find_children_by_field_name, named_children, node_text, Source};
5
5
 
6
- /// C++ `function_definition` also covers pure-virtual/`= default`/`= delete` members; those have no
7
- /// `body` and are signatures, not implementations, matching how TypeScript method signatures are
8
- /// excluded. Java `method_declaration` is NOT here: PMD reports abstract/interface methods as
9
- /// methods (NCSS 1), so bodyless Java methods stay in the function list (as do C#'s and Kotlin's).
10
- /// C# auto-property accessors (`{ get; set; }`) and Kotlin visibility-only accessors (`private
6
+ /// Declarations without a body have no control flow, so they are signatures, not functions: C++
7
+ /// pure-virtual/`= default`/`= delete` members, abstract/interface/extern methods (Java, C#,
8
+ /// Kotlin), Go assembly-backed function declarations, and Rust trait method signatures, matching
9
+ /// how TypeScript method signatures are excluded. C# auto-property accessors (`{ get; set; }`) and Kotlin visibility-only accessors (`private
11
10
  /// set`) hold no code, so they need a body too; a C# property or indexer is a function only in its
12
11
  /// expression-bodied form (`int X => ...`), otherwise its accessors are the functions.
13
12
  const BODY_REQUIRED_FUNCTION_TYPES: &[&str] = &[
14
13
  "function_definition",
14
+ "function_declaration",
15
+ "method_declaration",
15
16
  "constructor_declaration",
16
17
  "compact_constructor_declaration",
17
18
  "function_signature_item",
@@ -35,8 +36,9 @@ pub fn is_implemented_function(node: Node<'_>) -> bool {
35
36
  .is_some_and(|value| value.kind() == "arrow_expression_clause");
36
37
  }
37
38
 
38
- // The Kotlin grammar has no fields; an implemented accessor holds a `function_body` child.
39
- if node.kind() == "getter" || node.kind() == "setter" {
39
+ // The Kotlin grammar has no fields; an implemented function or accessor holds a
40
+ // `function_body` child.
41
+ if node.kind() == "getter" || node.kind() == "setter" || node.kind() == "function_declaration" {
40
42
  return named_children(node)
41
43
  .iter()
42
44
  .any(|child| child.kind() == "function_body");
package/native/src/lib.rs CHANGED
@@ -21,7 +21,7 @@ mod util;
21
21
  /// together with `expectedPayloadVersion` in src/nativeMetrics.ts.
22
22
  #[napi]
23
23
  pub fn payload_version() -> u32 {
24
- 4
24
+ 5
25
25
  }
26
26
 
27
27
  /// Measures code metrics for the given source, returning the NativeMetrics payload as JSON.
@@ -53,6 +53,8 @@ pub fn measure(
53
53
  // yields the JavaScript string (UTF-16 code unit) column.
54
54
  start_column: node.start_position().column / 2,
55
55
  end_line: node.end_position().row + 1,
56
+ end_column: node.end_position().column / 2,
57
+ cyclomatic_complexity: body_metrics.cyclomatic_complexity,
56
58
  // Sonar's written spec adds +1 cognitive complexity per function in a recursion
57
59
  // cycle, but this is intentionally not implemented (issue #22): mainstream
58
60
  // implementations (PMD, SonarQube analyzers) omit it.
@@ -40,6 +40,8 @@ pub struct FunctionMetrics {
40
40
  pub start_line: usize,
41
41
  pub start_column: usize,
42
42
  pub end_line: usize,
43
+ pub end_column: usize,
44
+ pub cyclomatic_complexity: u64,
43
45
  pub cognitive_complexity: u64,
44
46
  pub nesting_depth: u64,
45
47
  pub ncss: u64,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-gauge",
3
- "version": "4.2.2",
3
+ "version": "4.3.1",
4
4
  "description": "Measure code metrics with tree-sitter.",
5
5
  "keywords": [
6
6
  "cli",
@@ -65,16 +65,16 @@
65
65
  "@tsconfig/bun": "1.0.10",
66
66
  "@tsconfig/node-lts": "24.0.0",
67
67
  "@tsconfig/node-ts": "23.6.4",
68
- "@types/bun": "1.4.0",
68
+ "@types/bun": "1.4.2",
69
69
  "@types/node": "25.9.4",
70
70
  "@willbooster/oxfmt-config": "1.2.2",
71
71
  "@willbooster/oxlint-config": "1.4.8",
72
- "@willbooster/wb": "22.1.3",
73
- "build-ts": "21.0.10",
72
+ "@willbooster/wb": "22.7.0",
73
+ "build-ts": "21.0.16",
74
74
  "conventional-changelog-conventionalcommits": "9.3.1",
75
- "lefthook": "2.1.10",
76
- "oxfmt": "0.64.0",
77
- "oxlint": "1.79.0",
75
+ "lefthook": "2.1.14",
76
+ "oxfmt": "0.68.0",
77
+ "oxlint": "1.83.0",
78
78
  "oxlint-tsgolint": "7.0.2001",
79
79
  "semantic-release": "25.0.5",
80
80
  "sort-package-json": "4.0.0",
@@ -89,12 +89,12 @@
89
89
  "registry": "https://registry.npmjs.org/"
90
90
  },
91
91
  "optionalDependencies": {
92
- "code-gauge-linux-x64-gnu": "4.2.2",
93
- "code-gauge-linux-arm64-gnu": "4.2.2",
94
- "code-gauge-linux-x64-musl": "4.2.2",
95
- "code-gauge-linux-arm64-musl": "4.2.2",
96
- "code-gauge-darwin-x64": "4.2.2",
97
- "code-gauge-darwin-arm64": "4.2.2",
98
- "code-gauge-win32-x64-msvc": "4.2.2"
92
+ "code-gauge-linux-x64-gnu": "4.3.1",
93
+ "code-gauge-linux-arm64-gnu": "4.3.1",
94
+ "code-gauge-linux-x64-musl": "4.3.1",
95
+ "code-gauge-linux-arm64-musl": "4.3.1",
96
+ "code-gauge-darwin-x64": "4.3.1",
97
+ "code-gauge-darwin-arm64": "4.3.1",
98
+ "code-gauge-win32-x64-msvc": "4.3.1"
99
99
  }
100
100
  }