codecaliper 0.1.0__py3-none-any.whl

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.
Files changed (46) hide show
  1. codecaliper/__init__.py +64 -0
  2. codecaliper/_version.py +1 -0
  3. codecaliper/api.py +446 -0
  4. codecaliper/canonical.py +130 -0
  5. codecaliper/cli.py +231 -0
  6. codecaliper/errors.py +27 -0
  7. codecaliper/languages/__init__.py +44 -0
  8. codecaliper/languages/base.py +162 -0
  9. codecaliper/languages/java.py +192 -0
  10. codecaliper/languages/python.py +179 -0
  11. codecaliper/metrics/__init__.py +0 -0
  12. codecaliper/metrics/base.py +55 -0
  13. codecaliper/metrics/cognitive.py +111 -0
  14. codecaliper/metrics/cyclomatic.py +62 -0
  15. codecaliper/metrics/halstead.py +55 -0
  16. codecaliper/metrics/loc.py +62 -0
  17. codecaliper/metrics/mi.py +31 -0
  18. codecaliper/model.py +132 -0
  19. codecaliper/py.typed +0 -0
  20. codecaliper/readability/__init__.py +13 -0
  21. codecaliper/readability/base.py +11 -0
  22. codecaliper/readability/bw2010.py +156 -0
  23. codecaliper/readability/granularity.py +210 -0
  24. codecaliper/readability/retrain.py +67 -0
  25. codecaliper/spec/__init__.py +3 -0
  26. codecaliper/spec/registry.py +112 -0
  27. codecaliper/spec/rulings/bw.toml +156 -0
  28. codecaliper/spec/rulings/cognitive.toml +124 -0
  29. codecaliper/spec/rulings/core.toml +87 -0
  30. codecaliper/spec/rulings/cyclomatic.toml +172 -0
  31. codecaliper/spec/rulings/halstead.toml +20 -0
  32. codecaliper/spec/rulings/index.toml +54 -0
  33. codecaliper/spec/rulings/loc.toml +50 -0
  34. codecaliper/spec/rulings/mi.toml +15 -0
  35. codecaliper/spec/rulings/tokenization.toml +191 -0
  36. codecaliper/spec/validated_grammars.toml +6 -0
  37. codecaliper/syntax/__init__.py +0 -0
  38. codecaliper/syntax/_treesitter.py +179 -0
  39. codecaliper/syntax/grammars.py +45 -0
  40. codecaliper/syntax/tokens.py +165 -0
  41. codecaliper-0.1.0.dist-info/METADATA +180 -0
  42. codecaliper-0.1.0.dist-info/RECORD +46 -0
  43. codecaliper-0.1.0.dist-info/WHEEL +4 -0
  44. codecaliper-0.1.0.dist-info/entry_points.txt +2 -0
  45. codecaliper-0.1.0.dist-info/licenses/LICENSE +21 -0
  46. codecaliper-0.1.0.dist-info/licenses/NOTICE +29 -0
@@ -0,0 +1,179 @@
1
+ """Python adapter: tables + contextual hooks, every row citing a ruling.
2
+
3
+ Verified against tree-sitter-python 0.25.0 node kinds (test_grammar_integrity
4
+ holds these tables to the compiled grammar).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import keyword
10
+ from typing import TYPE_CHECKING
11
+
12
+ from codecaliper.languages.base import Classified, LanguageAdapter, NodeClass
13
+ from codecaliper.spec import require
14
+
15
+ if TYPE_CHECKING: # pragma: no cover
16
+ from codecaliper.syntax._treesitter import Node
17
+
18
+ R_CC_IF = require("CC-PY-0001")
19
+ R_CC_ELIF = require("CC-PY-0002")
20
+ R_CC_BOOLOP = require("CC-PY-0003")
21
+ R_CC_COMP_GUARD = require("CC-PY-0004")
22
+ R_CC_LOOP = require("CC-PY-0005")
23
+ R_CC_EXCEPT = require("CC-PY-0006")
24
+ R_CC_TERNARY = require("CC-PY-0007")
25
+ R_CC_CASE = require("CC-PY-0008")
26
+ R_COG_STRUCT = require("COG-ALL-0001")
27
+ R_COG_HYBRID = require("COG-ALL-0002")
28
+ R_COG_BOOLSEQ = require("COG-ALL-0003")
29
+ R_COG_NESTING = require("COG-ALL-0004")
30
+ R_COG_COMPREHENSION = require("COG-PY-0001")
31
+ R_NESTED_UNITS = require("CORE-ALL-0003")
32
+ R_TOK_ELLIPSIS = require("TOK-PY-0003")
33
+
34
+ # BW-PY-0001's enumerated soft-keyword set (match/case/type/_), pinned so
35
+ # classification is identical across the supported interpreters; asserted at
36
+ # import time to be a superset of the running interpreter's softkwlist so a
37
+ # future CPython addition can't drift silently past this table.
38
+ _SOFT_KEYWORDS = frozenset({"_", "case", "match", "type"})
39
+ assert _SOFT_KEYWORDS >= frozenset(keyword.softkwlist), (
40
+ f"host softkwlist {keyword.softkwlist} exceeds the pinned BW-PY-0001 set "
41
+ f"{sorted(_SOFT_KEYWORDS)} — reconcile the ruling before shipping"
42
+ )
43
+
44
+ _NODE_CLASS_MAP: dict[str, tuple[NodeClass, tuple[str, ...]]] = {
45
+ "if_statement": (NodeClass.BRANCH, (R_CC_IF, R_COG_STRUCT)),
46
+ "elif_clause": (NodeClass.ELIF_CONTINUATION, (R_CC_ELIF, R_COG_HYBRID)),
47
+ "conditional_expression": (NodeClass.TERNARY, (R_CC_TERNARY, R_COG_STRUCT)),
48
+ "for_statement": (NodeClass.LOOP, (R_CC_LOOP, R_COG_STRUCT)),
49
+ "while_statement": (NodeClass.LOOP, (R_CC_LOOP, R_COG_STRUCT)),
50
+ "except_clause": (NodeClass.CATCH, (R_CC_EXCEPT, R_COG_STRUCT)),
51
+ "match_statement": (NodeClass.SWITCH, (R_COG_STRUCT,)),
52
+ "case_clause": (NodeClass.CASE_LABEL, (R_CC_CASE,)),
53
+ "if_clause": (NodeClass.COMPREHENSION_GUARD, (R_CC_COMP_GUARD, R_COG_COMPREHENSION)),
54
+ "lambda": (NodeClass.LAMBDA, (R_COG_NESTING,)),
55
+ "function_definition": (NodeClass.FUNCTION_DEF, (R_NESTED_UNITS, R_COG_NESTING)),
56
+ "class_definition": (NodeClass.CLASS_DEF, (R_NESTED_UNITS,)),
57
+ "try_statement": (NodeClass.NESTING_ONLY, ()),
58
+ "with_statement": (NodeClass.NESTING_ONLY, ()),
59
+ }
60
+
61
+
62
+ class PythonAdapter(LanguageAdapter):
63
+ def __init__(self) -> None:
64
+ super().__init__(
65
+ name="python",
66
+ file_extensions=(".py", ".pyi"),
67
+ # BW-PY-0001: kwlist (True/False/None included; soft keywords are identifiers)
68
+ keywords=frozenset(keyword.kwlist),
69
+ # BW-PY-0001: soft keywords are identifiers, matching tokenize NAME.
70
+ # PINNED to the spec's enumerated set rather than the running
71
+ # interpreter's keyword.softkwlist — `type` (PEP 695) is absent from
72
+ # softkwlist before 3.12, which would make provenance for a `type`
73
+ # alias differ across the supported 3.10-3.14 interpreters.
74
+ soft_keywords=_SOFT_KEYWORDS,
75
+ keyword_leaf_types=frozenset({"true", "false", "none"}),
76
+ identifier_types=frozenset({"identifier"}),
77
+ # TOK-PY-0003: `...` (named `ellipsis` leaf) is an OPERATOR token,
78
+ # matching CPython tokenize's OP and Java's varargs `...`; otherwise
79
+ # it falls through to OTHER and vanishes from Halstead
80
+ operator_leaf_types=frozenset({"ellipsis"}),
81
+ number_types=frozenset({"integer", "float"}),
82
+ string_types=frozenset({"string"}),
83
+ comment_types=frozenset({"comment"}),
84
+ # TOK-PY-0002: `string` is atomic; `concatenated_string` is
85
+ # descended so interleaved comments lex as COMMENT tokens
86
+ atomic_types=frozenset({"string"}),
87
+ # BW-ALL-0006 operator classes (spellings mirror the reference extractor)
88
+ arithmetic_ops=frozenset({"+", "-", "*", "/", "//", "%", "**", "@"}),
89
+ comparison_ops=frozenset({"==", "!=", "<", ">", "<=", ">=", "<>"}),
90
+ assignment_ops=frozenset(
91
+ {"=", "+=", "-=", "*=", "/=", "//=", "%=", "**=",
92
+ "&=", "|=", "^=", ">>=", "<<=", "@=", ":="}
93
+ ),
94
+ branch_keywords=frozenset({"if", "elif"}), # BW-PY-0002
95
+ loop_keywords=frozenset({"for", "while"}), # BW-PY-0002
96
+ node_class_map=dict(_NODE_CLASS_MAP),
97
+ statement_types=frozenset(
98
+ {
99
+ "expression_statement", "return_statement", "pass_statement",
100
+ "break_statement", "continue_statement", "import_statement",
101
+ "import_from_statement", "future_import_statement",
102
+ "raise_statement", "assert_statement",
103
+ "delete_statement", "global_statement", "nonlocal_statement",
104
+ "if_statement", "for_statement", "while_statement", "try_statement",
105
+ "with_statement", "function_definition", "class_definition",
106
+ "match_statement", "type_alias_statement",
107
+ # legacy py2 syntax the grammar still recognizes
108
+ "print_statement", "exec_statement",
109
+ }
110
+ ),
111
+ function_def_types=frozenset({"function_definition"}),
112
+ class_def_types=frozenset({"class_definition"}),
113
+ # TOK-PY-0003 governs only when an `...` actually lexes; cited
114
+ # per-occurrence, not statically
115
+ conditional_token_rulings={"ellipsis": R_TOK_ELLIPSIS},
116
+ )
117
+
118
+ def classify(self, node: Node) -> Classified | None:
119
+ t = node.type
120
+ if t == "if_clause":
121
+ # CC-PY-0004 scopes to comprehension guards. A case-clause guard is
122
+ # not separately counted: the guarded case_clause already counts
123
+ # (CC-PY-0008), and counting the guard too would double-count.
124
+ parent = node.parent
125
+ if parent is not None and parent.type == "case_clause":
126
+ return None
127
+ return super().classify(node)
128
+ if t == "case_clause":
129
+ # CC-PY-0008: a bare-wildcard `case _:` with no guard is the
130
+ # fall-through path, mirroring Java `default:` (CC-JAVA-0006).
131
+ patterns = [c for c in node.children if c.type == "case_pattern"]
132
+ has_guard = node.child_by_field_name("guard") is not None
133
+ if (
134
+ len(patterns) == 1
135
+ and (patterns[0].text or b"") == b"_"
136
+ and not has_guard
137
+ ):
138
+ return None
139
+ return super().classify(node)
140
+ if t == "else_clause":
141
+ # COG-ALL-0002: hybrid +1 only for an if-attached else; for/while/try
142
+ # else clauses are not branch arms.
143
+ parent = node.parent
144
+ if parent is not None and parent.type == "if_statement":
145
+ return Classified(NodeClass.ELSE_CLAUSE, (R_COG_HYBRID,))
146
+ return None
147
+ if t == "boolean_operator":
148
+ op = node.child_by_field_name("operator")
149
+ op_type = op.type if op is not None else ""
150
+ parent = node.parent
151
+ same_as_parent = False
152
+ if parent is not None and parent.type == "boolean_operator":
153
+ pop = parent.child_by_field_name("operator")
154
+ same_as_parent = pop is not None and pop.type == op_type
155
+ return Classified(
156
+ NodeClass.BOOL_OP, (R_CC_BOOLOP, R_COG_BOOLSEQ), new_sequence=not same_as_parent
157
+ )
158
+ return super().classify(node)
159
+
160
+ def call_name(self, node: Node) -> str | None:
161
+ """COG-ALL-0005 receiver rule: bare calls plus self./cls. method calls."""
162
+ if node.type != "call":
163
+ return None
164
+ fn = node.child_by_field_name("function")
165
+ if fn is None:
166
+ return None
167
+ if fn.type == "identifier":
168
+ return (fn.text or b"").decode("utf-8", errors="replace")
169
+ if fn.type == "attribute":
170
+ obj = fn.child_by_field_name("object")
171
+ attr = fn.child_by_field_name("attribute")
172
+ if (
173
+ obj is not None
174
+ and obj.type == "identifier"
175
+ and (obj.text or b"") in (b"self", b"cls")
176
+ and attr is not None
177
+ ):
178
+ return (attr.text or b"").decode("utf-8", errors="replace")
179
+ return None
File without changes
@@ -0,0 +1,55 @@
1
+ """MetricContext — the trace/audit seam every increment routes through.
2
+
3
+ ``ctx.count(ruling, node, delta)`` records which rulings actually fired (for
4
+ ``Provenance.rulings_applied``) and, only when ``explain=True``, a per-increment
5
+ :class:`RulingTrace` — zero cost on the default path (ARCHITECTURE.md §3.3).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING
12
+
13
+ from codecaliper.model import RulingTrace, Span
14
+
15
+ if TYPE_CHECKING: # pragma: no cover
16
+ from codecaliper.syntax._treesitter import Node
17
+
18
+
19
+ @dataclass
20
+ class MetricContext:
21
+ cognitive_mode: str = "whitepaper" # "whitepaper" | "sonar-compat"
22
+ explain: bool = False
23
+ line_range: tuple[int, int] | None = None # CORE-JAVA-0001 scaffold restriction
24
+ line_offset: int = 0 # scaffold lines to subtract so trace spans stay in
25
+ # original snippet coordinates (CORE-JAVA-0001)
26
+ domain: tuple[int, int] | None = None # 1-based line domain of the MEASURED
27
+ # text; trace spans are clamped into it so a root-node trace can neither
28
+ # leak scaffold coordinates (CORE-JAVA-0001) nor point past the last
29
+ # physical line
30
+ fired: set[str] = field(default_factory=set)
31
+ traces: dict[str, list[RulingTrace]] = field(default_factory=dict)
32
+
33
+ def in_range(self, node: Node) -> bool:
34
+ if self.line_range is None:
35
+ return True
36
+ lo, hi = self.line_range
37
+ return lo <= node.start_point[0] + 1 <= hi
38
+
39
+ def count(self, ruling_id: str, node: Node, delta: float = 1.0, metric: str = "") -> float:
40
+ self.fired.add(ruling_id)
41
+ if self.explain:
42
+ start_line = node.start_point[0] + 1 - self.line_offset
43
+ end_line = node.end_point[0] + 1 - self.line_offset
44
+ if self.domain is not None:
45
+ lo, hi = self.domain
46
+ start_line = min(max(start_line, lo), hi)
47
+ end_line = min(max(end_line, lo), hi)
48
+ span = Span(
49
+ start_line, node.start_point[1], end_line, node.end_point[1],
50
+ )
51
+ self.traces.setdefault(metric, []).append(RulingTrace(ruling_id, span, delta))
52
+ return delta
53
+
54
+ def trace_for(self, metric: str) -> tuple[RulingTrace, ...]:
55
+ return tuple(self.traces.get(metric, ()))
@@ -0,0 +1,111 @@
1
+ """Cognitive complexity — ONE walker, dual mode (COG-* rulings).
2
+
3
+ Default mode is whitepaper-faithful; sonar-compat matches the deployed
4
+ Sonar-lineage implementations where they diverge from the paper text. The only
5
+ seed divergence is the recursion increment (COG-ALL-0005 whitepaper /
6
+ COG-ALL-0006 sonar-compat); every future divergence must arrive as a new
7
+ mode-tagged ruling pair, keeping the spec table the authoritative mode diff.
8
+
9
+ The chain-flattening walk generalizes the proven ``_traverse`` walker from
10
+ Spaghetti Architect's eval/metrics.py onto tree-sitter (NOTICE-credited).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ from codecaliper.languages.base import LanguageAdapter, NodeClass
18
+ from codecaliper.metrics.base import MetricContext
19
+ from codecaliper.spec import require
20
+
21
+ if TYPE_CHECKING: # pragma: no cover
22
+ from codecaliper.syntax._treesitter import Node
23
+
24
+ R_STRUCT = require("COG-ALL-0001")
25
+ R_HYBRID = require("COG-ALL-0002")
26
+ R_BOOLSEQ = require("COG-ALL-0003")
27
+ R_NESTING = require("COG-ALL-0004")
28
+ R_RECURSION_WP = require("COG-ALL-0005")
29
+ R_NO_RECURSION_SONAR = require("COG-ALL-0006")
30
+
31
+ _STRUCTURAL = frozenset(
32
+ {NodeClass.BRANCH, NodeClass.LOOP, NodeClass.CATCH, NodeClass.SWITCH, NodeClass.TERNARY}
33
+ )
34
+ _HYBRID = frozenset({NodeClass.ELIF_CONTINUATION, NodeClass.ELSE_CLAUSE})
35
+
36
+
37
+ def cognitive(
38
+ root: Node,
39
+ adapter: LanguageAdapter,
40
+ ctx: MetricContext,
41
+ *,
42
+ exclude_nested_units: bool = False,
43
+ initial_function_names: tuple[str, ...] = (),
44
+ ) -> int:
45
+ """Cognitive complexity of ``root``'s subtree.
46
+
47
+ ``initial_function_names`` seeds the enclosing-function name stack when the
48
+ root itself is a function unit (recursion heuristic, COG-ALL-0005).
49
+ """
50
+ total = 0
51
+ whitepaper = ctx.cognitive_mode == "whitepaper"
52
+ fdepth0 = 1 if initial_function_names else 0
53
+ # stack entries: (node, nesting, function_depth, enclosing_function_names)
54
+ stack: list[tuple[Node, int, int, tuple[str, ...]]] = [
55
+ (child, 0, fdepth0, initial_function_names) for child in reversed(root.children)
56
+ ]
57
+ from codecaliper.syntax._treesitter import is_opaque
58
+
59
+ while stack:
60
+ node, nesting, fdepth, names = stack.pop()
61
+ if is_opaque(node):
62
+ continue # CORE-ALL-0002: ERROR/MISSING subtrees are opaque
63
+ child_nesting, child_fdepth, child_names = nesting, fdepth, names
64
+ c = adapter.classify(node)
65
+ if c is not None:
66
+ k = c.node_class
67
+ if k in _STRUCTURAL:
68
+ if ctx.in_range(node):
69
+ total += int(ctx.count(R_STRUCT, node, 1 + nesting, metric="cognitive"))
70
+ if nesting > 0:
71
+ ctx.fired.add(R_NESTING) # the nesting penalty rule applied
72
+ child_nesting += 1 # COG-ALL-0004
73
+ elif k in _HYBRID:
74
+ if ctx.in_range(node):
75
+ total += int(ctx.count(R_HYBRID, node, 1, metric="cognitive"))
76
+ # chain flattening: no extra nesting beyond the chain head's
77
+ elif k is NodeClass.BOOL_OP:
78
+ if c.new_sequence and ctx.in_range(node):
79
+ total += int(ctx.count(R_BOOLSEQ, node, 1, metric="cognitive"))
80
+ elif k is NodeClass.JUMP_LABEL:
81
+ # labeled jumps: flat +1, both modes, no nesting effect
82
+ # (whitepaper B1 fundamental increment; sonar-java implements it)
83
+ if ctx.in_range(node):
84
+ for r in c.rulings:
85
+ if r.startswith("COG-"):
86
+ total += int(ctx.count(r, node, 1, metric="cognitive"))
87
+ break
88
+ elif k in (NodeClass.FUNCTION_DEF, NodeClass.LAMBDA):
89
+ if exclude_nested_units and k is NodeClass.FUNCTION_DEF:
90
+ continue # CORE-ALL-0003: nested units get their own reports
91
+ if fdepth > 0:
92
+ child_nesting += 1 # COG-ALL-0004: only NESTED units deepen
93
+ ctx.fired.add(R_NESTING)
94
+ child_fdepth += 1
95
+ if k is NodeClass.FUNCTION_DEF:
96
+ child_names = names + (adapter.function_name(node),)
97
+ elif k is NodeClass.CLASS_DEF:
98
+ if exclude_nested_units:
99
+ continue
100
+ # class bodies do not deepen cognitive nesting (COG-ALL-0004)
101
+ if whitepaper and names:
102
+ callee = adapter.call_name(node)
103
+ if callee is not None and callee == names[-1] and ctx.in_range(node):
104
+ total += int(ctx.count(R_RECURSION_WP, node, 1, metric="cognitive"))
105
+ elif not whitepaper:
106
+ ctx.fired.add(R_NO_RECURSION_SONAR)
107
+ stack.extend(
108
+ (child, child_nesting, child_fdepth, child_names)
109
+ for child in reversed(node.children)
110
+ )
111
+ return total
@@ -0,0 +1,62 @@
1
+ """Cyclomatic complexity: 1 + decision points over NodeClass (CC-* rulings)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from codecaliper.languages.base import LanguageAdapter, NodeClass
8
+ from codecaliper.metrics.base import MetricContext
9
+ from codecaliper.spec import require
10
+
11
+ if TYPE_CHECKING: # pragma: no cover
12
+ from codecaliper.syntax._treesitter import Node
13
+
14
+ R_BASE = require("CC-ALL-0001")
15
+
16
+ _DECISION_CLASSES = frozenset(
17
+ {
18
+ NodeClass.BRANCH,
19
+ NodeClass.ELIF_CONTINUATION,
20
+ NodeClass.LOOP,
21
+ NodeClass.CATCH,
22
+ NodeClass.CASE_LABEL,
23
+ NodeClass.TERNARY,
24
+ NodeClass.COMPREHENSION_GUARD,
25
+ NodeClass.BOOL_OP,
26
+ }
27
+ )
28
+
29
+
30
+ def cyclomatic(
31
+ root: Node,
32
+ adapter: LanguageAdapter,
33
+ ctx: MetricContext,
34
+ *,
35
+ exclude_nested_units: bool = False,
36
+ ) -> int:
37
+ """Decision points in ``root``'s subtree (root itself excluded from unit
38
+ exclusion so a function node can be measured as a unit, CORE-ALL-0003)."""
39
+ from codecaliper.syntax._treesitter import is_opaque
40
+
41
+ # the base path is itself a counted increment: CC-ALL-0001 fires for every
42
+ # emitted cyclomatic value, so provenance and --explain carry it
43
+ total = int(ctx.count(R_BASE, root, 1, metric="cyclomatic"))
44
+ stack = list(reversed(root.children))
45
+ while stack:
46
+ node = stack.pop()
47
+ if is_opaque(node):
48
+ continue # CORE-ALL-0002: ERROR/MISSING subtrees are opaque
49
+ c = adapter.classify(node)
50
+ if c is not None:
51
+ if exclude_nested_units and c.node_class in (
52
+ NodeClass.FUNCTION_DEF,
53
+ NodeClass.CLASS_DEF,
54
+ ):
55
+ continue # nested units get their own reports (CORE-ALL-0003)
56
+ if c.node_class in _DECISION_CLASSES and ctx.in_range(node):
57
+ for r in c.rulings:
58
+ if r.startswith("CC-"):
59
+ total += int(ctx.count(r, node, 1, metric="cyclomatic"))
60
+ break
61
+ stack.extend(reversed(node.children))
62
+ return total
@@ -0,0 +1,55 @@
1
+ """Lexical Halstead over the unified token stream (HAL-ALL-0001).
2
+
3
+ Deliberately uniform cross-language: operators are OPERATOR/KEYWORD/PUNCT
4
+ tokens, operands are IDENTIFIER/NUMBER/STRING tokens. Absolute values are
5
+ implementation-defined (only trends/ratios are stable); every emitted value
6
+ carries the halstead-approximation diagnostic. The divergence vs AST-harvest
7
+ implementations (radon, the stdlib reference lane) is declared (no Halstead
8
+ differential oracle runs; the label is the disclosure).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+
15
+ from codecaliper.model import Diagnostic
16
+ from codecaliper.spec import require
17
+ from codecaliper.syntax.tokens import LexicalToken, TokenKind
18
+
19
+ R_LEXICAL = require("HAL-ALL-0001")
20
+
21
+ APPROX_DIAGNOSTIC = Diagnostic(
22
+ severity="info",
23
+ code="halstead-approximation",
24
+ message="Halstead values are a lexical approximation; absolute values are "
25
+ "implementation-defined, only trends/ratios are stable (HAL-ALL-0001).",
26
+ ruling=R_LEXICAL,
27
+ )
28
+
29
+ _OPERATOR_KINDS = frozenset({TokenKind.OPERATOR, TokenKind.KEYWORD, TokenKind.PUNCT})
30
+ _OPERAND_KINDS = frozenset({TokenKind.IDENTIFIER, TokenKind.NUMBER, TokenKind.STRING})
31
+
32
+
33
+ def halstead(tokens: list[LexicalToken]) -> dict[str, float]:
34
+ operators: list[str] = []
35
+ operands: list[str] = []
36
+ for tok in tokens:
37
+ if tok.kind in _OPERATOR_KINDS:
38
+ operators.append(tok.text)
39
+ elif tok.kind in _OPERAND_KINDS:
40
+ operands.append(tok.text)
41
+ n1, big_n1 = len(set(operators)), len(operators)
42
+ n2, big_n2 = len(set(operands)), len(operands)
43
+ n, big_n = n1 + n2, big_n1 + big_n2
44
+ volume = big_n * math.log2(n) if n > 0 else 0.0
45
+ difficulty = (n1 / 2) * (big_n2 / n2) if n2 > 0 else 0.0
46
+ effort = difficulty * volume
47
+ return {
48
+ "halstead.n1": float(n1),
49
+ "halstead.N1": float(big_n1),
50
+ "halstead.n2": float(n2),
51
+ "halstead.N2": float(big_n2),
52
+ "halstead.volume": volume,
53
+ "halstead.difficulty": difficulty,
54
+ "halstead.effort": effort,
55
+ }
@@ -0,0 +1,62 @@
1
+ """Line counting (LOC-* rulings) — coverage measures over the token stream.
2
+
3
+ sloc counts lines covered by code-token spans, so multi-line strings count on
4
+ every line they span; this is exactly where the demoted regex lane provably
5
+ fails (ARCHITECTURE.md §6) and the corpus proves it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from codecaliper.languages.base import LanguageAdapter
13
+ from codecaliper.metrics.base import MetricContext
14
+ from codecaliper.spec import require
15
+ from codecaliper.syntax.tokens import LexicalToken, TokenKind
16
+
17
+ if TYPE_CHECKING: # pragma: no cover
18
+ from codecaliper.syntax._treesitter import Node
19
+
20
+ R_PHYSICAL = require("LOC-ALL-0001")
21
+ R_SLOC = require("LOC-ALL-0002")
22
+ R_CLOC = require("LOC-ALL-0003")
23
+ R_LLOC = require("LOC-ALL-0004")
24
+ R_TOK_LINES = require("TOK-ALL-0005") # line attribution for sloc/comment coverage
25
+
26
+
27
+ def loc_metrics(
28
+ lines: list[str],
29
+ tokens: list[LexicalToken],
30
+ root: Node,
31
+ adapter: LanguageAdapter,
32
+ ctx: MetricContext,
33
+ ) -> dict[str, int]:
34
+ physical = len(lines)
35
+ blank = sum(1 for ln in lines if not ln.strip())
36
+
37
+ code_lines: set[int] = set()
38
+ comment_lines: set[int] = set()
39
+ for tok in tokens:
40
+ target = comment_lines if tok.kind is TokenKind.COMMENT else code_lines
41
+ for line in range(tok.line, tok.end_line + 1):
42
+ target.add(line)
43
+
44
+ from codecaliper.syntax._treesitter import is_opaque
45
+
46
+ lloc = 0
47
+ stack = [root]
48
+ while stack:
49
+ node = stack.pop()
50
+ if is_opaque(node):
51
+ continue # CORE-ALL-0002: ERROR/MISSING subtrees are opaque
52
+ if node.type in adapter.statement_types and ctx.in_range(node):
53
+ lloc += int(ctx.count(R_LLOC, node, 1, metric="lloc"))
54
+ stack.extend(reversed(node.children))
55
+
56
+ return {
57
+ "physical_lines": physical,
58
+ "blank_lines": blank,
59
+ "sloc": len(code_lines),
60
+ "comment_lines": len(comment_lines),
61
+ "lloc": lloc,
62
+ }
@@ -0,0 +1,31 @@
1
+ """Maintainability index (MI-ALL-0001) — derived, and it says so in the types.
2
+
3
+ MI contains the CC term; it is NOT independent of cyclomatic complexity
4
+ (ARCHITECTURE.md §13). Every value carries derived_from and mi-contains-cc.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ from codecaliper.model import Diagnostic
12
+ from codecaliper.spec import require
13
+
14
+ R_MI = require("MI-ALL-0001")
15
+
16
+ DERIVED_FROM = ("halstead.volume", "cyclomatic", "sloc")
17
+
18
+ CONTAINS_CC_DIAGNOSTIC = Diagnostic(
19
+ severity="info",
20
+ code="mi-contains-cc",
21
+ message="MI is derived from Halstead volume, cyclomatic complexity and SLOC; "
22
+ "do not treat MI and CC as independent signals (MI-ALL-0001).",
23
+ ruling=R_MI,
24
+ )
25
+
26
+
27
+ def maintainability_index(volume: float, cc: int, sloc: int) -> float:
28
+ v = max(volume, 1e-9)
29
+ s = max(sloc, 1)
30
+ mi = (171.0 - 5.2 * math.log(v) - 0.23 * cc - 16.2 * math.log(s)) * 100.0 / 171.0
31
+ return max(0.0, min(100.0, mi))