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,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
+ ]
@@ -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
+ )
@@ -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