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
codecaliper/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""codecaliper — a cross-language code readability + complexity measurement
|
|
2
|
+
instrument with a versioned metric-to-syntax specification.
|
|
3
|
+
|
|
4
|
+
Every emitted number is traceable (spec version, fired rulings, grammar
|
|
5
|
+
versions) and reproducible (clock-free, hash-seed-free, order-stable per
|
|
6
|
+
platform). See ARCHITECTURE.md; honesty boundaries are in the docs and encoded
|
|
7
|
+
in the result types.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from codecaliper._version import __version__
|
|
11
|
+
from codecaliper.api import DEFAULT_METRICS, Session, measure, measure_file
|
|
12
|
+
from codecaliper.errors import (
|
|
13
|
+
CodecaliperError,
|
|
14
|
+
GrammarLoadError,
|
|
15
|
+
LanguageDetectionError,
|
|
16
|
+
SpecError,
|
|
17
|
+
StrictParseError,
|
|
18
|
+
UnsupportedLanguageError,
|
|
19
|
+
)
|
|
20
|
+
from codecaliper.languages import available_languages, detect_language
|
|
21
|
+
from codecaliper.model import (
|
|
22
|
+
Diagnostic,
|
|
23
|
+
FeatureVectorResult,
|
|
24
|
+
FileReport,
|
|
25
|
+
FunctionReport,
|
|
26
|
+
GrammarInfo,
|
|
27
|
+
MetricValue,
|
|
28
|
+
Provenance,
|
|
29
|
+
RulingTrace,
|
|
30
|
+
Span,
|
|
31
|
+
)
|
|
32
|
+
from codecaliper.readability import BW_FEATURE_NAMES, BW_FEATURE_ORDER_SHA
|
|
33
|
+
from codecaliper.spec import Ruling, iter_rulings, ruling, spec_version
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"BW_FEATURE_NAMES",
|
|
37
|
+
"BW_FEATURE_ORDER_SHA",
|
|
38
|
+
"CodecaliperError",
|
|
39
|
+
"DEFAULT_METRICS",
|
|
40
|
+
"Diagnostic",
|
|
41
|
+
"FeatureVectorResult",
|
|
42
|
+
"FileReport",
|
|
43
|
+
"FunctionReport",
|
|
44
|
+
"GrammarInfo",
|
|
45
|
+
"GrammarLoadError",
|
|
46
|
+
"LanguageDetectionError",
|
|
47
|
+
"MetricValue",
|
|
48
|
+
"Provenance",
|
|
49
|
+
"Ruling",
|
|
50
|
+
"RulingTrace",
|
|
51
|
+
"Session",
|
|
52
|
+
"Span",
|
|
53
|
+
"SpecError",
|
|
54
|
+
"StrictParseError",
|
|
55
|
+
"UnsupportedLanguageError",
|
|
56
|
+
"__version__",
|
|
57
|
+
"available_languages",
|
|
58
|
+
"detect_language",
|
|
59
|
+
"iter_rulings",
|
|
60
|
+
"measure",
|
|
61
|
+
"measure_file",
|
|
62
|
+
"ruling",
|
|
63
|
+
"spec_version",
|
|
64
|
+
]
|
codecaliper/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
codecaliper/api.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""The typed public facade: measure() / measure_file() / Session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Collection
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from codecaliper._version import __version__
|
|
10
|
+
from codecaliper.errors import CodecaliperError, StrictParseError
|
|
11
|
+
from codecaliper.languages import detect_language, get_adapter
|
|
12
|
+
from codecaliper.languages.base import LanguageAdapter
|
|
13
|
+
from codecaliper.metrics import cognitive as cog_mod
|
|
14
|
+
from codecaliper.metrics import cyclomatic as cc_mod
|
|
15
|
+
from codecaliper.metrics import halstead as hal_mod
|
|
16
|
+
from codecaliper.metrics import loc as loc_mod
|
|
17
|
+
from codecaliper.metrics import mi as mi_mod
|
|
18
|
+
from codecaliper.metrics.base import MetricContext
|
|
19
|
+
from codecaliper.model import (
|
|
20
|
+
Diagnostic,
|
|
21
|
+
FileReport,
|
|
22
|
+
FunctionReport,
|
|
23
|
+
Granularity,
|
|
24
|
+
MetricValue,
|
|
25
|
+
Provenance,
|
|
26
|
+
)
|
|
27
|
+
from codecaliper.readability import granularity as gran_mod
|
|
28
|
+
from codecaliper.spec import spec_version
|
|
29
|
+
from codecaliper.syntax import _treesitter as ts
|
|
30
|
+
from codecaliper.syntax import tokens as tok_mod
|
|
31
|
+
|
|
32
|
+
DEFAULT_METRICS = ("cyclomatic", "cognitive", "halstead", "maintainability_index", "loc")
|
|
33
|
+
_FEATURE_SETS = ("bw2010",)
|
|
34
|
+
_GRANULARITIES = ("snippet", "function", "file")
|
|
35
|
+
_COGNITIVE_MODES = ("whitepaper", "sonar-compat")
|
|
36
|
+
|
|
37
|
+
CognitiveMode = Literal["whitepaper", "sonar-compat"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Session:
|
|
41
|
+
"""Caches adapters/parsers — the corpus-scale entry point (deterministic
|
|
42
|
+
sequential order; parallelism is a 1.x item)."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
metrics: Collection[str] | None = None,
|
|
48
|
+
readability: Collection[str] = ("bw2010",),
|
|
49
|
+
granularity: Granularity = "file",
|
|
50
|
+
cognitive_mode: CognitiveMode = "whitepaper",
|
|
51
|
+
explain: bool = False,
|
|
52
|
+
strict: bool = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.metrics = tuple(metrics) if metrics is not None else DEFAULT_METRICS
|
|
55
|
+
unknown = [m for m in self.metrics if m not in DEFAULT_METRICS]
|
|
56
|
+
if unknown:
|
|
57
|
+
raise CodecaliperError(
|
|
58
|
+
f"unknown metric(s) {unknown!r}; available: {', '.join(DEFAULT_METRICS)}"
|
|
59
|
+
)
|
|
60
|
+
self.readability = tuple(readability)
|
|
61
|
+
unknown_fs = [f for f in self.readability if f not in _FEATURE_SETS]
|
|
62
|
+
if unknown_fs:
|
|
63
|
+
raise CodecaliperError(
|
|
64
|
+
f"unknown readability feature set(s) {unknown_fs!r}; "
|
|
65
|
+
f"available: {', '.join(_FEATURE_SETS)}"
|
|
66
|
+
)
|
|
67
|
+
# Literal types only protect type-checked callers; an invalid mode
|
|
68
|
+
# would otherwise silently measure as sonar-compat and stamp an
|
|
69
|
+
# internally inconsistent provenance.
|
|
70
|
+
if granularity not in _GRANULARITIES:
|
|
71
|
+
raise CodecaliperError(
|
|
72
|
+
f"unknown granularity {granularity!r}; "
|
|
73
|
+
f"available: {', '.join(_GRANULARITIES)}"
|
|
74
|
+
)
|
|
75
|
+
if cognitive_mode not in _COGNITIVE_MODES:
|
|
76
|
+
raise CodecaliperError(
|
|
77
|
+
f"unknown cognitive mode {cognitive_mode!r}; "
|
|
78
|
+
f"available: {', '.join(_COGNITIVE_MODES)}"
|
|
79
|
+
)
|
|
80
|
+
self.granularity: Granularity = granularity
|
|
81
|
+
self.cognitive_mode: CognitiveMode = cognitive_mode
|
|
82
|
+
self.explain = explain
|
|
83
|
+
self.strict = strict
|
|
84
|
+
|
|
85
|
+
def measure(
|
|
86
|
+
self, source: str | bytes, *, language: str, path: str | None = None
|
|
87
|
+
) -> FileReport:
|
|
88
|
+
return _measure(
|
|
89
|
+
source,
|
|
90
|
+
adapter=get_adapter(language),
|
|
91
|
+
path=path,
|
|
92
|
+
metrics=self.metrics,
|
|
93
|
+
readability=self.readability,
|
|
94
|
+
granularity=self.granularity,
|
|
95
|
+
cognitive_mode=self.cognitive_mode,
|
|
96
|
+
explain=self.explain,
|
|
97
|
+
strict=self.strict,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def measure_file(self, path: str | os.PathLike[str], *, language: str | None = None) -> FileReport:
|
|
101
|
+
p = os.fspath(path)
|
|
102
|
+
lang = language if language is not None else detect_language(p)
|
|
103
|
+
with open(p, "rb") as f:
|
|
104
|
+
data = f.read()
|
|
105
|
+
return self.measure(data, language=lang, path=p)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def measure(
|
|
109
|
+
source: str | bytes,
|
|
110
|
+
*,
|
|
111
|
+
language: str,
|
|
112
|
+
metrics: Collection[str] | None = None,
|
|
113
|
+
readability: Collection[str] = ("bw2010",),
|
|
114
|
+
granularity: Granularity = "file",
|
|
115
|
+
cognitive_mode: CognitiveMode = "whitepaper",
|
|
116
|
+
explain: bool = False,
|
|
117
|
+
strict: bool = False,
|
|
118
|
+
) -> FileReport:
|
|
119
|
+
return Session(
|
|
120
|
+
metrics=metrics,
|
|
121
|
+
readability=readability,
|
|
122
|
+
granularity=granularity,
|
|
123
|
+
cognitive_mode=cognitive_mode,
|
|
124
|
+
explain=explain,
|
|
125
|
+
strict=strict,
|
|
126
|
+
).measure(source, language=language)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def measure_file(
|
|
130
|
+
path: str | os.PathLike[str], *, language: str | None = None, **kwargs: object
|
|
131
|
+
) -> FileReport:
|
|
132
|
+
return Session(**kwargs).measure_file(path, language=language) # type: ignore[arg-type]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _measure(
|
|
136
|
+
source: str | bytes,
|
|
137
|
+
*,
|
|
138
|
+
adapter: LanguageAdapter,
|
|
139
|
+
path: str | None,
|
|
140
|
+
metrics: tuple[str, ...],
|
|
141
|
+
readability: tuple[str, ...],
|
|
142
|
+
granularity: Granularity,
|
|
143
|
+
cognitive_mode: CognitiveMode,
|
|
144
|
+
explain: bool,
|
|
145
|
+
strict: bool,
|
|
146
|
+
) -> FileReport:
|
|
147
|
+
text, diags = tok_mod.normalize(source)
|
|
148
|
+
diagnostics = list(diags)
|
|
149
|
+
lines = tok_mod.source_lines(text)
|
|
150
|
+
|
|
151
|
+
# --- parse (with the Java bare-snippet scaffold when needed, CORE-JAVA-0001)
|
|
152
|
+
scaffolded = False
|
|
153
|
+
line_offset = 0
|
|
154
|
+
line_range: tuple[int, int] | None = None
|
|
155
|
+
source_bytes = text.encode("utf-8")
|
|
156
|
+
tree = adapter.parse(source_bytes)
|
|
157
|
+
n_errors = ts.count_error_nodes(tree)
|
|
158
|
+
if n_errors and granularity == "snippet" and adapter.name == "java":
|
|
159
|
+
# Two candidates: class-body-only (rescues constructors/modifier-bearing
|
|
160
|
+
# members) and class+method (statement fragments). Adopt the strict
|
|
161
|
+
# error-count minimizer (CORE-JAVA-0001).
|
|
162
|
+
best = None
|
|
163
|
+
for prefix, suffix, offset in (
|
|
164
|
+
(gran_mod.JAVA_CLASS_PREFIX, gran_mod.JAVA_CLASS_SUFFIX,
|
|
165
|
+
gran_mod.JAVA_CLASS_LINE_OFFSET),
|
|
166
|
+
(gran_mod.JAVA_SNIPPET_PREFIX, gran_mod.JAVA_SNIPPET_SUFFIX,
|
|
167
|
+
gran_mod.JAVA_SNIPPET_LINE_OFFSET),
|
|
168
|
+
):
|
|
169
|
+
wrapped_bytes = (prefix + text + suffix).encode("utf-8")
|
|
170
|
+
wrapped_tree = adapter.parse(wrapped_bytes)
|
|
171
|
+
errs = ts.count_error_nodes(wrapped_tree)
|
|
172
|
+
if best is None or errs < best[0]:
|
|
173
|
+
best = (errs, wrapped_bytes, wrapped_tree, offset)
|
|
174
|
+
if best is not None and best[0] < n_errors:
|
|
175
|
+
scaffolded = True
|
|
176
|
+
_, source_bytes, tree, line_offset = best
|
|
177
|
+
n_errors = best[0]
|
|
178
|
+
line_range = (line_offset + 1, line_offset + len(lines))
|
|
179
|
+
# CORE-JAVA-0001 promises a report-level trace: every metric below
|
|
180
|
+
# was computed from the synthetically scaffolded parse, so the
|
|
181
|
+
# diagnostic must survive a metrics-only run (readability=()).
|
|
182
|
+
diagnostics.append(
|
|
183
|
+
Diagnostic(
|
|
184
|
+
"info", "snippet-scaffolded",
|
|
185
|
+
"bare Java snippet parsed inside a synthetic scaffold; all "
|
|
186
|
+
"values computed over the original snippet lines only "
|
|
187
|
+
"(CORE-JAVA-0001)",
|
|
188
|
+
ruling="CORE-JAVA-0001",
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
parse_ok = n_errors == 0
|
|
193
|
+
if not parse_ok:
|
|
194
|
+
if strict:
|
|
195
|
+
raise StrictParseError(
|
|
196
|
+
f"{path or '<source>'}: parse tree contains {n_errors} ERROR/MISSING node(s)"
|
|
197
|
+
)
|
|
198
|
+
diagnostics.append(
|
|
199
|
+
Diagnostic(
|
|
200
|
+
"warning", "parse-error-recovered",
|
|
201
|
+
f"parse tree contains {n_errors} ERROR/MISSING node(s); measured "
|
|
202
|
+
"anyway with error subtrees opaque (CORE-ALL-0002)",
|
|
203
|
+
ruling="CORE-ALL-0002",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
domain = (1, max(len(lines), 1)) # emitted traces may not point outside it
|
|
208
|
+
ctx = MetricContext(
|
|
209
|
+
cognitive_mode=cognitive_mode, explain=explain,
|
|
210
|
+
line_range=line_range, line_offset=line_offset, domain=domain,
|
|
211
|
+
)
|
|
212
|
+
# TOK-ALL-0003 attaches no diagnostic by design, so its firing is detected
|
|
213
|
+
# from the raw input directly (every diagnostic-cited ruling is harvested
|
|
214
|
+
# into rulings_applied just before Provenance is built).
|
|
215
|
+
had_cr = b"\r" in source if isinstance(source, bytes) else "\r" in source
|
|
216
|
+
if had_cr:
|
|
217
|
+
ctx.fired.add("TOK-ALL-0003")
|
|
218
|
+
# cond_opaque/cond_full record, per conditional tokenization ruling, the
|
|
219
|
+
# physical lines it engaged on in the error-opaque (metrics) stream and the
|
|
220
|
+
# ERROR-inclusive (BW fallback) stream respectively, so each value cites
|
|
221
|
+
# them only over the span it was actually computed on.
|
|
222
|
+
cond_opaque: dict[str, set[int]] = {}
|
|
223
|
+
all_tokens = tok_mod.lex(tree, source_bytes, adapter, cond_lines=cond_opaque)
|
|
224
|
+
if line_range is not None:
|
|
225
|
+
tokens = gran_mod.rebase_tokens(all_tokens, line_range[0], line_range[1])
|
|
226
|
+
else:
|
|
227
|
+
tokens = all_tokens
|
|
228
|
+
# The language's static atomic-string rulings govern every token-derived
|
|
229
|
+
# value; construct-specific classification rulings (TOK-ALL-0007,
|
|
230
|
+
# TOK-JAVA-0002) are cited only over ranges where they actually engaged.
|
|
231
|
+
tok_lang_rulings = tok_mod.lang_tokenization_rulings(adapter)
|
|
232
|
+
measured_lo, measured_hi = line_range if line_range is not None else domain
|
|
233
|
+
file_cond = gran_mod.cond_in_range(cond_opaque, measured_lo, measured_hi)
|
|
234
|
+
|
|
235
|
+
# --- file-level metrics. cc/loc are also MI inputs; when their own metric
|
|
236
|
+
# is not requested they are computed under a SCRATCH context so
|
|
237
|
+
# rulings_applied reports only rulings backing EMITTED values.
|
|
238
|
+
file_metrics: list[MetricValue] = []
|
|
239
|
+
root = tree.root_node
|
|
240
|
+
|
|
241
|
+
def _scratch() -> MetricContext:
|
|
242
|
+
return MetricContext(cognitive_mode=cognitive_mode, line_range=line_range,
|
|
243
|
+
line_offset=line_offset, domain=domain)
|
|
244
|
+
|
|
245
|
+
need_cc = "cyclomatic" in metrics or "maintainability_index" in metrics
|
|
246
|
+
need_loc = "loc" in metrics or "maintainability_index" in metrics
|
|
247
|
+
cc_value = 0
|
|
248
|
+
if need_cc:
|
|
249
|
+
cc_value = cc_mod.cyclomatic(
|
|
250
|
+
root, adapter, ctx if "cyclomatic" in metrics else _scratch()
|
|
251
|
+
)
|
|
252
|
+
loc_values: dict[str, int] = {}
|
|
253
|
+
if need_loc:
|
|
254
|
+
loc_values = loc_mod.loc_metrics(
|
|
255
|
+
lines, tokens, root, adapter, ctx if "loc" in metrics else _scratch()
|
|
256
|
+
)
|
|
257
|
+
if "cyclomatic" in metrics:
|
|
258
|
+
file_metrics.append(
|
|
259
|
+
MetricValue("cyclomatic", cc_value, rulings=_cc_rulings(adapter),
|
|
260
|
+
trace=ctx.trace_for("cyclomatic"))
|
|
261
|
+
)
|
|
262
|
+
if "cognitive" in metrics:
|
|
263
|
+
cog_value = cog_mod.cognitive(root, adapter, ctx)
|
|
264
|
+
file_metrics.append(
|
|
265
|
+
MetricValue("cognitive", cog_value,
|
|
266
|
+
rulings=_cog_rulings(adapter, cognitive_mode),
|
|
267
|
+
trace=ctx.trace_for("cognitive"))
|
|
268
|
+
)
|
|
269
|
+
hal_values: dict[str, float] = {}
|
|
270
|
+
if "halstead" in metrics or "maintainability_index" in metrics:
|
|
271
|
+
hal_values = hal_mod.halstead(tokens)
|
|
272
|
+
if "halstead" in metrics:
|
|
273
|
+
# atomicity + word-token classification shape the operator/operand split;
|
|
274
|
+
# Halstead is computed over the whole (opaque) file stream, so it cites
|
|
275
|
+
# the conditional rulings that engaged anywhere in the measured range
|
|
276
|
+
hal_rulings = (hal_mod.R_LEXICAL,) + tok_lang_rulings + file_cond
|
|
277
|
+
for r in hal_rulings:
|
|
278
|
+
ctx.fired.add(r)
|
|
279
|
+
for name, value in hal_values.items():
|
|
280
|
+
file_metrics.append(
|
|
281
|
+
MetricValue(name, value, rulings=hal_rulings,
|
|
282
|
+
diagnostics=(hal_mod.APPROX_DIAGNOSTIC,))
|
|
283
|
+
)
|
|
284
|
+
if "maintainability_index" in metrics:
|
|
285
|
+
mi_value = mi_mod.maintainability_index(
|
|
286
|
+
hal_values["halstead.volume"], cc_value, loc_values["sloc"]
|
|
287
|
+
)
|
|
288
|
+
ctx.fired.add(mi_mod.R_MI)
|
|
289
|
+
file_metrics.append(
|
|
290
|
+
MetricValue(
|
|
291
|
+
"maintainability_index", mi_value, rulings=(mi_mod.R_MI,),
|
|
292
|
+
derived_from=mi_mod.DERIVED_FROM,
|
|
293
|
+
diagnostics=(mi_mod.CONTAINS_CC_DIAGNOSTIC,),
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
if "loc" in metrics:
|
|
297
|
+
# TOK-ALL-0005 governs the line attribution sloc/comment_lines count
|
|
298
|
+
# over; the atomic-token rulings govern which spans exist to attribute
|
|
299
|
+
loc_rulings = ((loc_mod.R_PHYSICAL, loc_mod.R_SLOC, loc_mod.R_CLOC,
|
|
300
|
+
loc_mod.R_LLOC, loc_mod.R_TOK_LINES)
|
|
301
|
+
+ tok_lang_rulings + file_cond)
|
|
302
|
+
for r in loc_rulings:
|
|
303
|
+
ctx.fired.add(r)
|
|
304
|
+
for name, ivalue in loc_values.items():
|
|
305
|
+
file_metrics.append(
|
|
306
|
+
MetricValue(name, ivalue, rulings=loc_rulings,
|
|
307
|
+
trace=ctx.trace_for("lloc") if name == "lloc" else ())
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# --- per-function metrics (cyclomatic + cognitive; CORE-ALL-0003 exclusion)
|
|
311
|
+
units = adapter.function_units(tree)
|
|
312
|
+
if scaffolded:
|
|
313
|
+
# Drop phantom scaffold units (__CC__/__cc__ live on scaffold lines) and
|
|
314
|
+
# rebase spans back to original snippet coordinates (CORE-JAVA-0001).
|
|
315
|
+
lo, hi = line_range if line_range is not None else (1, len(lines))
|
|
316
|
+
units = [
|
|
317
|
+
gran_mod.rebase_unit(u, line_offset, len(lines))
|
|
318
|
+
for u in units
|
|
319
|
+
if lo <= u.span.start_line <= hi
|
|
320
|
+
]
|
|
321
|
+
functions: list[FunctionReport] = []
|
|
322
|
+
for unit in units:
|
|
323
|
+
fn_metrics: list[MetricValue] = []
|
|
324
|
+
if "cyclomatic" in metrics:
|
|
325
|
+
fn_cc = cc_mod.cyclomatic(unit.node, adapter, ctx, exclude_nested_units=True)
|
|
326
|
+
fn_metrics.append(MetricValue("cyclomatic", fn_cc, rulings=_cc_rulings(adapter)))
|
|
327
|
+
if "cognitive" in metrics:
|
|
328
|
+
fn_cog = cog_mod.cognitive(
|
|
329
|
+
unit.node, adapter, ctx, exclude_nested_units=True,
|
|
330
|
+
initial_function_names=(unit.name,),
|
|
331
|
+
)
|
|
332
|
+
fn_metrics.append(
|
|
333
|
+
MetricValue("cognitive", fn_cog,
|
|
334
|
+
rulings=_cog_rulings(adapter, cognitive_mode))
|
|
335
|
+
)
|
|
336
|
+
functions.append(
|
|
337
|
+
FunctionReport(unit.name, unit.qualified_name, unit.span, tuple(fn_metrics))
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# --- readability vectors
|
|
341
|
+
vectors = []
|
|
342
|
+
if "bw2010" in readability:
|
|
343
|
+
# BW-ALL-0007: the BW construct is lexical — on parse errors its
|
|
344
|
+
# token-family features use the full leaf stream, ERROR regions
|
|
345
|
+
# included. Every metric above stays error-opaque (CORE-ALL-0002).
|
|
346
|
+
bw_tokens = tokens
|
|
347
|
+
lexical_fallback = False
|
|
348
|
+
cond_full: dict[str, set[int]] = {}
|
|
349
|
+
if not parse_ok:
|
|
350
|
+
full = tok_mod.lex(tree, source_bytes, adapter,
|
|
351
|
+
include_error_tokens=True, cond_lines=cond_full)
|
|
352
|
+
if line_range is not None:
|
|
353
|
+
full = gran_mod.rebase_tokens(full, line_range[0], line_range[1])
|
|
354
|
+
lexical_fallback = len(full) != len(tokens)
|
|
355
|
+
if lexical_fallback:
|
|
356
|
+
bw_tokens = full
|
|
357
|
+
if granularity == "function":
|
|
358
|
+
vectors = gran_mod.function_vectors(
|
|
359
|
+
lines, bw_tokens, adapter, units,
|
|
360
|
+
opaque_tokens=tokens if lexical_fallback else None,
|
|
361
|
+
cond_opaque=cond_opaque,
|
|
362
|
+
cond_full=cond_full,
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
# single file/snippet vector over the whole measured range, on
|
|
366
|
+
# whichever stream it used
|
|
367
|
+
vec_cond = gran_mod.cond_in_range(
|
|
368
|
+
cond_full if lexical_fallback else cond_opaque,
|
|
369
|
+
measured_lo, measured_hi,
|
|
370
|
+
)
|
|
371
|
+
vectors = [
|
|
372
|
+
gran_mod.vector(lines, bw_tokens, adapter, granularity,
|
|
373
|
+
scaffolded=scaffolded, lexical_fallback=lexical_fallback,
|
|
374
|
+
cond_rulings=vec_cond)
|
|
375
|
+
]
|
|
376
|
+
for v in vectors:
|
|
377
|
+
ctx.fired.update(v.rulings)
|
|
378
|
+
# The report-level trace appears only when an EMITTED vector actually
|
|
379
|
+
# engaged the fallback: at granularity="function" an ERROR region
|
|
380
|
+
# outside every unit changes no emitted number, so a report-level
|
|
381
|
+
# BW-ALL-0007 claim would be false.
|
|
382
|
+
if any("BW-ALL-0007" in v.rulings for v in vectors):
|
|
383
|
+
diagnostics.append(
|
|
384
|
+
Diagnostic(
|
|
385
|
+
"info", "bw-lexical-fallback",
|
|
386
|
+
"parse errors present; bw2010 token-family features were "
|
|
387
|
+
"computed over the full lexical stream, ERROR regions "
|
|
388
|
+
"included (BW-ALL-0007)",
|
|
389
|
+
ruling="BW-ALL-0007",
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Provenance must not contradict the report's own diagnostics: every
|
|
394
|
+
# diagnostic-cited ruling observably shaped the run (TOK-ALL-0001/0002
|
|
395
|
+
# normalization, CORE-ALL-0002 error opacity, CORE-JAVA-0001 scaffolding,
|
|
396
|
+
# BW-ALL-0007 fallback), so it belongs in rulings_applied.
|
|
397
|
+
for d in diagnostics:
|
|
398
|
+
if d.ruling:
|
|
399
|
+
ctx.fired.add(d.ruling)
|
|
400
|
+
provenance = Provenance(
|
|
401
|
+
tool_version=__version__,
|
|
402
|
+
spec_version=spec_version(),
|
|
403
|
+
language=adapter.name,
|
|
404
|
+
grammar=adapter.grammar_info(),
|
|
405
|
+
modes=(("cognitive", cognitive_mode),),
|
|
406
|
+
rulings_applied=tuple(sorted(ctx.fired)),
|
|
407
|
+
)
|
|
408
|
+
if not provenance.grammar.validated:
|
|
409
|
+
diagnostics.append(
|
|
410
|
+
Diagnostic(
|
|
411
|
+
"warning", "unvalidated-grammar",
|
|
412
|
+
f"installed {provenance.grammar.package} {provenance.grammar.version} is "
|
|
413
|
+
"not the release-calibrated version; results are honest but "
|
|
414
|
+
"uncalibrated (ARCHITECTURE.md §10)",
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
return FileReport(
|
|
419
|
+
path=path,
|
|
420
|
+
parse_ok=parse_ok,
|
|
421
|
+
file_metrics=tuple(file_metrics),
|
|
422
|
+
functions=tuple(functions),
|
|
423
|
+
readability=tuple(vectors),
|
|
424
|
+
diagnostics=tuple(diagnostics),
|
|
425
|
+
provenance=provenance,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _cc_rulings(adapter: LanguageAdapter) -> tuple[str, ...]:
|
|
430
|
+
from codecaliper.spec import iter_rulings
|
|
431
|
+
|
|
432
|
+
return tuple(
|
|
433
|
+
sorted(r.id for r in iter_rulings(metric="cyclomatic", language=adapter.name))
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _cog_rulings(adapter: LanguageAdapter, mode: str) -> tuple[str, ...]:
|
|
438
|
+
from codecaliper.spec import iter_rulings
|
|
439
|
+
|
|
440
|
+
return tuple(
|
|
441
|
+
sorted(
|
|
442
|
+
r.id
|
|
443
|
+
for r in iter_rulings(metric="cognitive", language=adapter.name)
|
|
444
|
+
if r.mode in ("", mode)
|
|
445
|
+
)
|
|
446
|
+
)
|
codecaliper/canonical.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Canonical, deterministic serialization (CORE-ALL-0004).
|
|
2
|
+
|
|
3
|
+
No timestamps, no hash-order dependence; floats are quantized to 12 significant
|
|
4
|
+
digits (round-half-even via repr of the formatted value). Byte-identical output
|
|
5
|
+
is a per-platform guarantee — tests/test_determinism.py enforces it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import fields, is_dataclass
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
|
|
16
|
+
from codecaliper.model import FileReport
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def qfloat(x: float) -> float:
|
|
20
|
+
"""Quantize to 12 significant digits (CORE-ALL-0004)."""
|
|
21
|
+
return float(f"{x:.12g}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _plain(obj: Any) -> Any:
|
|
25
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
|
26
|
+
return {f.name: _plain(getattr(obj, f.name)) for f in fields(obj)}
|
|
27
|
+
if isinstance(obj, tuple) and obj and isinstance(obj[0], tuple) and len(obj[0]) == 2:
|
|
28
|
+
# mode pairs -> object
|
|
29
|
+
try:
|
|
30
|
+
return {k: _plain(v) for k, v in obj}
|
|
31
|
+
except (TypeError, ValueError):
|
|
32
|
+
return [_plain(v) for v in obj]
|
|
33
|
+
if isinstance(obj, (list, tuple)):
|
|
34
|
+
return [_plain(v) for v in obj]
|
|
35
|
+
if isinstance(obj, dict):
|
|
36
|
+
return {k: _plain(v) for k, v in obj.items()}
|
|
37
|
+
if isinstance(obj, float):
|
|
38
|
+
return qfloat(obj)
|
|
39
|
+
return obj
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def to_dict(report: FileReport) -> dict[str, Any]:
|
|
43
|
+
return cast("dict[str, Any]", _plain(report))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def to_json(report: FileReport) -> str:
|
|
47
|
+
return json.dumps(to_dict(report), ensure_ascii=False, indent=2) + "\n"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
#: Wide-format CSV: one row per scope, flat metric + BW columns, plus MANDATORY
|
|
51
|
+
#: provenance columns — a pasted CSV row still names its spec (ARCHITECTURE.md §9).
|
|
52
|
+
_PROVENANCE_COLUMNS = (
|
|
53
|
+
"spec_version", "tool_version", "language", "grammar_package", "grammar_version",
|
|
54
|
+
"grammar_validated", "cognitive_mode", "parse_ok",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_csv(reports: list[FileReport]) -> str:
|
|
59
|
+
metric_names: list[str] = []
|
|
60
|
+
bw_names: list[str] = []
|
|
61
|
+
for rep in reports:
|
|
62
|
+
for mv in rep.file_metrics:
|
|
63
|
+
if mv.metric not in metric_names:
|
|
64
|
+
metric_names.append(mv.metric)
|
|
65
|
+
for rvec in rep.readability:
|
|
66
|
+
for n in rvec.names:
|
|
67
|
+
if n not in bw_names:
|
|
68
|
+
bw_names.append(n)
|
|
69
|
+
header = (
|
|
70
|
+
["path", "scope", "name", "start_line", "end_line"]
|
|
71
|
+
+ metric_names
|
|
72
|
+
+ [f"bw_{n}" for n in bw_names]
|
|
73
|
+
+ ["bw_granularity", "bw_extrapolated"]
|
|
74
|
+
+ list(_PROVENANCE_COLUMNS)
|
|
75
|
+
)
|
|
76
|
+
buf = io.StringIO()
|
|
77
|
+
w = csv.writer(buf, lineterminator="\n")
|
|
78
|
+
w.writerow(header)
|
|
79
|
+
for rep in reports:
|
|
80
|
+
prov = rep.provenance
|
|
81
|
+
assert prov is not None
|
|
82
|
+
prov_cells = [
|
|
83
|
+
prov.spec_version, prov.tool_version, prov.language, prov.grammar.package,
|
|
84
|
+
prov.grammar.version, prov.grammar.validated, dict(prov.modes)["cognitive"],
|
|
85
|
+
rep.parse_ok,
|
|
86
|
+
]
|
|
87
|
+
file_vec = next(
|
|
88
|
+
(v for v in rep.readability if v.granularity in ("file", "snippet")), None
|
|
89
|
+
)
|
|
90
|
+
file_metrics = {mv.metric: mv.value for mv in rep.file_metrics}
|
|
91
|
+
row: list[Any] = [rep.path or "", "file", "", "", ""]
|
|
92
|
+
row += [_cell(file_metrics.get(m)) for m in metric_names]
|
|
93
|
+
if file_vec is not None:
|
|
94
|
+
fv = dict(zip(file_vec.names, file_vec.values, strict=True))
|
|
95
|
+
row += [_cell(fv.get(n)) for n in bw_names]
|
|
96
|
+
row += [file_vec.granularity, file_vec.extrapolated]
|
|
97
|
+
else:
|
|
98
|
+
row += ["" for _ in bw_names] + ["", ""]
|
|
99
|
+
row += prov_cells
|
|
100
|
+
w.writerow(row)
|
|
101
|
+
# keyed by (name, span): qualified names alone are not unique (Java
|
|
102
|
+
# overloads, Python redefinitions) and would misattribute vectors
|
|
103
|
+
fn_vecs = {
|
|
104
|
+
(v.unit_name, v.span.start_line, v.span.end_line): v
|
|
105
|
+
for v in rep.readability
|
|
106
|
+
if v.granularity == "function" and v.span is not None
|
|
107
|
+
}
|
|
108
|
+
for fn in rep.functions:
|
|
109
|
+
fm = {mv.metric: mv.value for mv in fn.metrics}
|
|
110
|
+
row = [rep.path or "", "function", fn.qualified_name,
|
|
111
|
+
fn.span.start_line, fn.span.end_line]
|
|
112
|
+
row += [_cell(fm.get(m)) for m in metric_names]
|
|
113
|
+
vec = fn_vecs.get((fn.qualified_name, fn.span.start_line, fn.span.end_line))
|
|
114
|
+
if vec is not None:
|
|
115
|
+
fv = dict(zip(vec.names, vec.values, strict=True))
|
|
116
|
+
row += [_cell(fv.get(n)) for n in bw_names]
|
|
117
|
+
row += [vec.granularity, vec.extrapolated]
|
|
118
|
+
else:
|
|
119
|
+
row += ["" for _ in bw_names] + ["", ""]
|
|
120
|
+
row += prov_cells
|
|
121
|
+
w.writerow(row)
|
|
122
|
+
return buf.getvalue()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _cell(v: Any) -> Any:
|
|
126
|
+
if v is None:
|
|
127
|
+
return ""
|
|
128
|
+
if isinstance(v, float):
|
|
129
|
+
return qfloat(v)
|
|
130
|
+
return v
|