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.
- codecaliper/__init__.py +64 -0
- codecaliper/_version.py +1 -0
- codecaliper/api.py +446 -0
- codecaliper/canonical.py +130 -0
- codecaliper/cli.py +231 -0
- codecaliper/errors.py +27 -0
- codecaliper/languages/__init__.py +44 -0
- codecaliper/languages/base.py +162 -0
- codecaliper/languages/java.py +192 -0
- codecaliper/languages/python.py +179 -0
- codecaliper/metrics/__init__.py +0 -0
- codecaliper/metrics/base.py +55 -0
- codecaliper/metrics/cognitive.py +111 -0
- codecaliper/metrics/cyclomatic.py +62 -0
- codecaliper/metrics/halstead.py +55 -0
- codecaliper/metrics/loc.py +62 -0
- codecaliper/metrics/mi.py +31 -0
- codecaliper/model.py +132 -0
- codecaliper/py.typed +0 -0
- codecaliper/readability/__init__.py +13 -0
- codecaliper/readability/base.py +11 -0
- codecaliper/readability/bw2010.py +156 -0
- codecaliper/readability/granularity.py +210 -0
- codecaliper/readability/retrain.py +67 -0
- codecaliper/spec/__init__.py +3 -0
- codecaliper/spec/registry.py +112 -0
- codecaliper/spec/rulings/bw.toml +156 -0
- codecaliper/spec/rulings/cognitive.toml +124 -0
- codecaliper/spec/rulings/core.toml +87 -0
- codecaliper/spec/rulings/cyclomatic.toml +172 -0
- codecaliper/spec/rulings/halstead.toml +20 -0
- codecaliper/spec/rulings/index.toml +54 -0
- codecaliper/spec/rulings/loc.toml +50 -0
- codecaliper/spec/rulings/mi.toml +15 -0
- codecaliper/spec/rulings/tokenization.toml +191 -0
- codecaliper/spec/validated_grammars.toml +6 -0
- codecaliper/syntax/__init__.py +0 -0
- codecaliper/syntax/_treesitter.py +179 -0
- codecaliper/syntax/grammars.py +45 -0
- codecaliper/syntax/tokens.py +165 -0
- codecaliper-0.1.0.dist-info/METADATA +180 -0
- codecaliper-0.1.0.dist-info/RECORD +46 -0
- codecaliper-0.1.0.dist-info/WHEEL +4 -0
- codecaliper-0.1.0.dist-info/entry_points.txt +2 -0
- codecaliper-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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))
|