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,54 @@
1
+ # The versioned metric-to-syntax mapping specification (ARCHITECTURE.md §3).
2
+ # SPEC semver is independent of the package version:
3
+ # patch = editorial; minor = new rulings, no existing number changes;
4
+ # major = any change that alters a number for any existing corpus case
5
+ # (including a grammar pin bump that does so).
6
+ [spec]
7
+ version = "1.1.0"
8
+
9
+ [[changelog]]
10
+ version = "1.1.0"
11
+ summary = """
12
+ Completions, no existing corpus value changes (MINOR): COG-JAVA-0001 added
13
+ (labeled break/continue = flat +1 in both modes — the whitepaper's fundamental
14
+ increment was silently missing for Java); TOK-ALL-0007 added (anonymous
15
+ word tokens outside the keyword table are identifiers — Python `__future__`
16
+ and Java contextual keywords previously fell through to OPERATOR, except
17
+ `yield`, which the Java keyword table wrongly reserved, contradicting
18
+ BW-JAVA-0001, so it lexed as KEYWORD); TOK-PY-0002 supersedes TOK-PY-0001
19
+ (`concatenated_string` is descended: implicit-concatenation parts are separate
20
+ STRING tokens and an interleaved comment is a COMMENT token — the superseded
21
+ ruling swallowed such comments into one string token, undercounting comment
22
+ lines); TOK-JAVA-0002 added (Java `_` is an IDENTIFIER token in every
23
+ position — `underscore_pattern` declarator uses previously lexed as OTHER,
24
+ while pattern/lambda uses already lexed as identifiers); TOK-PY-0003 added
25
+ (Python `...`, the named `ellipsis` leaf, lexes as an OPERATOR token and is
26
+ therefore visible to Halstead — previously it fell through to OTHER,
27
+ undercounting `.pyi`-shaped stub bodies); the lloc statement
28
+ tables were completed to match LOC-ALL-0004's stated scope (Python:
29
+ future_import_statement, type_alias_statement, legacy print/exec; Java:
30
+ package/import/interface/enum/record/annotation-type/constant declarations,
31
+ static initializers, compact constructors, explicit constructor invocations,
32
+ module declarations and directives). Numeric changes on inputs containing
33
+ those constructs only (including a Halstead change on `...`-bearing inputs);
34
+ every new ruling ships with its corpus case.
35
+ """
36
+
37
+ [[changelog]]
38
+ version = "1.0.0"
39
+ summary = """
40
+ First calibrated evolution, arbitrated by the pre-registered BW faithfulness
41
+ experiment (validation/bw_faithfulness/derived/arbitration_report.md):
42
+ TOK-ALL-0006 supersedes TOK-ALL-0004 (indentation tab = 8, was 1 — under
43
+ tab=1 the indentation/readability correlation contradicted BW Fig. 9);
44
+ BW-ALL-0007 added (BW token-family features use the full lexical stream,
45
+ ERROR regions included — the construct is lexical; coverage on the original
46
+ dataset 29/100 clean parses -> 100/100 full token streams). The operator-class
47
+ arbitration returned a NULL result: BW-ALL-0006 unchanged. Numeric changes:
48
+ indentation features on tab-indented lines; BW token features on parse-error
49
+ inputs. Every active ruling is corpus-covered (the 1.0 gate).
50
+ """
51
+
52
+ [[changelog]]
53
+ version = "0.1.0"
54
+ summary = "Seed ruling set: core policies, tokenization, CC/COG/HAL/MI/LOC, BW feature delimitation for Python + Java."
@@ -0,0 +1,50 @@
1
+ # LOC-*: line counting.
2
+
3
+ [[ruling]]
4
+ id = "LOC-ALL-0001"
5
+ metric = "loc"
6
+ language = "all"
7
+ title = "Physical and blank lines"
8
+ statement = """
9
+ physical_lines = number of lines of the normalized text (a trailing final
10
+ newline does not create an extra empty line). blank_lines = lines whose content
11
+ strips to empty.
12
+ """
13
+ examples = ["py-multiline-string-001", "py-blank-lines-001"]
14
+
15
+ [[ruling]]
16
+ id = "LOC-ALL-0002"
17
+ metric = "loc"
18
+ language = "all"
19
+ title = "sloc: lines covered by at least one code token"
20
+ statement = """
21
+ sloc counts lines covered by the span of at least one non-comment token. A
22
+ multi-line string is code: every line it spans counts. (This is where the old
23
+ regex lane provably fails — ARCHITECTURE.md §6.)
24
+ """
25
+ examples = ["py-multiline-string-001"]
26
+
27
+ [[ruling]]
28
+ id = "LOC-ALL-0003"
29
+ metric = "loc"
30
+ language = "all"
31
+ title = "comment_lines: lines covered by at least one comment token"
32
+ statement = """
33
+ comment_lines counts lines covered by the span of at least one comment token;
34
+ a multi-line block comment counts on EVERY line it spans. A line containing
35
+ both code and a trailing comment counts toward both sloc and comment_lines
36
+ (the two are coverage measures, not a partition).
37
+ """
38
+ examples = ["java-block-comment-001", "py-multiline-string-001"]
39
+
40
+ [[ruling]]
41
+ id = "LOC-ALL-0004"
42
+ metric = "loc"
43
+ language = "all"
44
+ title = "lloc: statement-classified nodes"
45
+ statement = """
46
+ lloc counts nodes in the adapter's statement table (simple statements plus
47
+ compound-statement headers plus declarations). The table is part of the spec
48
+ surface and validated against the grammar.
49
+ """
50
+ examples = ["py-cc-boolop-001", "java-elseif-001"]
@@ -0,0 +1,15 @@
1
+ # MI-*: maintainability index.
2
+
3
+ [[ruling]]
4
+ id = "MI-ALL-0001"
5
+ metric = "maintainability_index"
6
+ language = "all"
7
+ title = "MI variant: no comment term, clamped to 0-100"
8
+ statement = """
9
+ MI = clamp_0_100((171 - 5.2*ln(V) - 0.23*CC - 16.2*ln(SLOC)) * 100/171), the
10
+ Visual Studio / radon convention without the comment term. MI is DERIVED from
11
+ Halstead volume, cyclomatic complexity and SLOC — it is not independent of CC
12
+ (ARCHITECTURE.md §13); every MI value carries a typed derived_from and a standing
13
+ mi-contains-cc diagnostic.
14
+ """
15
+ examples = ["py-cc-boolop-001"]
@@ -0,0 +1,191 @@
1
+ # TOK-*: input normalization and the unified lexical token stream.
2
+ # These change emitted numbers, so they are spec, not implementation detail.
3
+
4
+ [[ruling]]
5
+ id = "TOK-ALL-0001"
6
+ metric = "tokenization"
7
+ language = "all"
8
+ title = "Input is decoded as UTF-8 with replacement"
9
+ statement = """
10
+ Byte input is decoded as UTF-8; undecodable bytes are replaced (U+FFFD) and an
11
+ encoding-replaced diagnostic is attached. str input is used as-is.
12
+ """
13
+ examples = ["py-tok-normalize-001"]
14
+
15
+ [[ruling]]
16
+ id = "TOK-ALL-0002"
17
+ metric = "tokenization"
18
+ language = "all"
19
+ title = "A leading UTF-8 BOM is stripped"
20
+ statement = """
21
+ A leading U+FEFF is stripped before measurement (with a bom-stripped
22
+ diagnostic); otherwise it would pollute line-1 character and length features.
23
+ """
24
+ examples = ["py-tok-normalize-001"]
25
+
26
+ [[ruling]]
27
+ id = "TOK-ALL-0003"
28
+ metric = "tokenization"
29
+ language = "all"
30
+ title = "Line endings are normalized to LF"
31
+ statement = """
32
+ CRLF and lone CR are normalized to LF before measurement, so character-level
33
+ features are platform-stable.
34
+ """
35
+ examples = ["py-tok-normalize-001"]
36
+
37
+ [[ruling]]
38
+ id = "TOK-ALL-0004"
39
+ metric = "tokenization"
40
+ language = "all"
41
+ title = "A tab counts as one indentation character (provisional)"
42
+ status = "superseded"
43
+ superseded_by = "TOK-ALL-0006"
44
+ statement = """
45
+ Indentation is measured as the count of leading whitespace characters; a tab
46
+ counts as 1. PROVISIONAL: the BW faithfulness pipeline (§6.3) will arbitrate
47
+ tab semantics empirically; any change supersedes this ruling.
48
+ """
49
+
50
+ [[ruling]]
51
+ id = "TOK-ALL-0005"
52
+ metric = "tokenization"
53
+ language = "all"
54
+ title = "Token line attribution"
55
+ statement = """
56
+ Every lexical token is attributed to the physical line of its start position.
57
+ A multi-line token (string, block comment) is ONE token on its start line for
58
+ token-count features; line-coverage features (sloc, comment lines) use its full
59
+ span instead.
60
+ """
61
+
62
+ [[ruling]]
63
+ id = "TOK-PY-0001"
64
+ metric = "tokenization"
65
+ language = "python"
66
+ title = "Python atomic tokens: strings are consumed whole"
67
+ status = "superseded"
68
+ superseded_by = "TOK-PY-0002"
69
+ statement = """
70
+ `string` and `concatenated_string` subtrees are consumed as single STRING
71
+ tokens. F-string interpolation contents therefore do NOT contribute token-level
72
+ features (identifiers/operators inside interpolations are invisible). This
73
+ diverges from CPython 3.12+ tokenize (PEP 701), which tokenizes interpolation
74
+ contents; the divergence vs the stdlib reference extractor is classified, and
75
+ uniformity with Java strings is preferred.
76
+ """
77
+ node_types = ["string", "concatenated_string"]
78
+
79
+ [[ruling]]
80
+ id = "TOK-PY-0002"
81
+ metric = "tokenization"
82
+ language = "python"
83
+ title = "Python atomic tokens: strings are consumed whole; implicit concatenation is descended"
84
+ since_spec = "1.1.0"
85
+ statement = """
86
+ `string` subtrees are consumed as single STRING tokens. F-string interpolation
87
+ contents therefore do NOT contribute token-level features (identifiers and
88
+ operators inside interpolations are invisible); this diverges from CPython
89
+ 3.12+ tokenize (PEP 701), the divergence vs the stdlib reference extractor is
90
+ classified, and uniformity with Java strings is preferred. A
91
+ `concatenated_string` (implicit concatenation) is NOT atomic: it is descended,
92
+ so each string part is its own STRING token and a comment interleaved between
93
+ parts is an ordinary COMMENT token — matching CPython tokenize. Supersedes
94
+ TOK-PY-0001, which consumed `concatenated_string` whole and thereby silently
95
+ swallowed interleaved comments (undercounting comment lines and counting
96
+ comment-only lines inside the concatenation as code).
97
+ """
98
+ node_types = ["string", "concatenated_string"]
99
+ examples = ["py-concat-string-001", "py-multiline-string-001"]
100
+
101
+ [[ruling]]
102
+ id = "TOK-PY-0003"
103
+ metric = "tokenization"
104
+ language = "python"
105
+ title = "Python `...` is an operator token"
106
+ since_spec = "1.1.0"
107
+ statement = """
108
+ The `...` literal (the grammar's named `ellipsis` leaf) lexes as an OPERATOR
109
+ token. This matches CPython tokenize (which emits an OP token for `...`) and
110
+ Java's varargs `...` (also OPERATOR), and keeps `...` visible to Halstead as
111
+ an operator — consistent with Python's other singleton literals (None/True/
112
+ False count as Halstead operators via keyword_leaf_types). Before 1.1.0 the
113
+ named `ellipsis` leaf fell through to OTHER and was invisible to Halstead
114
+ (systematically undercounting `.pyi` stub bodies, which are mostly `...`). BW
115
+ token-family features are unaffected either way: `...` matches none of the
116
+ counted operator spellings.
117
+ """
118
+ node_types = ["ellipsis"]
119
+ examples = ["py-ellipsis-001"]
120
+
121
+ [[ruling]]
122
+ id = "TOK-JAVA-0001"
123
+ metric = "tokenization"
124
+ language = "java"
125
+ title = "Java atomic tokens: string and character literals"
126
+ statement = """
127
+ `string_literal` and `character_literal` subtrees are consumed as single STRING
128
+ tokens (text blocks included).
129
+ """
130
+ node_types = ["string_literal", "character_literal"]
131
+
132
+ [[ruling]]
133
+ id = "TOK-JAVA-0002"
134
+ metric = "tokenization"
135
+ language = "java"
136
+ title = "Java `_` is an identifier token in every position"
137
+ since_spec = "1.1.0"
138
+ statement = """
139
+ The reserved word `_` (JLS §3.9, reserved since SE 9) lexes as an IDENTIFIER
140
+ token in every syntactic position. The grammar already yields `identifier`
141
+ nodes for `_` in switch patterns and lambda parameters; the
142
+ `underscore_pattern` node it yields for unnamed variable declarators is
143
+ classified identically, so the same source token cannot silently change token
144
+ class with syntactic position (before 1.1.0 it lexed as OTHER there —
145
+ invisible to BW token-family features and Halstead). Mirrors BW-PY-0001,
146
+ which counts Python's soft-keyword `_` as an identifier. BW-JAVA-0001's
147
+ keyword table deliberately omits `_`.
148
+ """
149
+ node_types = ["underscore_pattern"]
150
+ examples = ["java-underscore-001"]
151
+
152
+ [[ruling]]
153
+ id = "TOK-ALL-0007"
154
+ metric = "tokenization"
155
+ language = "all"
156
+ title = "Anonymous word tokens outside the keyword table are identifiers"
157
+ since_spec = "1.1.0"
158
+ statement = """
159
+ A grammar-anonymous leaf token whose text is word-shaped (letter/underscore
160
+ start, word characters, internal hyphens allowed) and not in the language's
161
+ keyword table is an IDENTIFIER token: Python's `__future__`, Java's contextual
162
+ keywords (record, sealed, non-sealed, permits, when, yield, module directives).
163
+ This matches the reference tokenizers, which yield NAME for all of them, and
164
+ keeps the keyword tables exactly the reserved-word sets (BW-PY-0001,
165
+ BW-JAVA-0001). Before 1.1.0 these tokens fell through to OPERATOR — except
166
+ Java's `yield`, which was wrongly in the keyword table (contradicting
167
+ BW-JAVA-0001's stated set) and lexed as KEYWORD.
168
+ """
169
+ examples = ["py-future-import-001", "java-contextual-001"]
170
+
171
+ [[ruling]]
172
+ id = "TOK-ALL-0006"
173
+ metric = "tokenization"
174
+ language = "all"
175
+ title = "A tab counts as 8 indentation characters (arbitrated)"
176
+ since_spec = "1.0.0"
177
+ statement = """
178
+ Indentation features count leading whitespace with a tab as 8 characters and a
179
+ space as 1. Supersedes TOK-ALL-0004 (tab = 1), whose PROVISIONAL marker was
180
+ resolved by the pre-registered 32-cell BW faithfulness arbitration
181
+ (validation/bw_faithfulness/derived/arbitration_report.md, 2026-07): under
182
+ tab = 1 the avg_indentation correlation with the human readability scores is
183
+ zero (rho +0.001), contradicting the paper's Fig. 9; any expansion >= 2 fixes
184
+ the sign in both extraction modes, and 8 won the pre-registered AUC tie-break
185
+ (honest caveat, per the independent verification: 8-vs-4 is a deterministic
186
+ convention pick within statistical noise at n=100 — both deliver the
187
+ Fig. 9-matching rho ~ -0.25; 2 does not). Scope: indentation features only;
188
+ line-length and space-count features remain raw character counts (a candidate
189
+ for future arbitration, noted in the report).
190
+ """
191
+ examples = ["py-tok-normalize-001"]
@@ -0,0 +1,6 @@
1
+ # The grammar versions this release is calibrated against (ARCHITECTURE.md §10).
2
+ # A deviating installed version still runs, but every report is stamped
3
+ # GrammarInfo.validated=false plus an unvalidated-grammar diagnostic.
4
+ [grammars]
5
+ python = "0.25.0"
6
+ java = "0.23.5"
File without changes
@@ -0,0 +1,179 @@
1
+ """The tree-sitter quarantine — the ONLY module that imports ``tree_sitter``.
2
+
3
+ py-tree-sitter breaks its API across 0.x minors (0.22 removed
4
+ ``Language(path, name)``; 0.23 removed the Parser setters; 0.24/0.25 moved
5
+ query execution to ``QueryCursor`` and deprecated ``Language.version`` for
6
+ ``abi_version``). Confining the import here bounds every future breakage to one
7
+ file (ARCHITECTURE.md §4). The MVP deliberately avoids the Query API entirely —
8
+ cursor walks suffice.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ import importlib.metadata
15
+ from collections.abc import Iterator
16
+ from typing import Any
17
+
18
+ from tree_sitter import Language, Node, Parser, Tree
19
+
20
+ from codecaliper.errors import GrammarLoadError
21
+
22
+ __all__ = ["Language", "Node", "Parser", "Tree", "load_language", "make_parser",
23
+ "walk", "leaves", "node_kinds", "count_error_nodes"]
24
+
25
+
26
+ def load_language(grammar_module: str) -> tuple[Language, str, int]:
27
+ """Import a grammar wheel and wrap its PyCapsule.
28
+
29
+ Returns ``(language, installed_version, abi_version)``.
30
+ """
31
+ try:
32
+ mod = importlib.import_module(grammar_module)
33
+ lang = Language(mod.language())
34
+ except Exception as exc: # noqa: BLE001 — surface as our typed error
35
+ raise GrammarLoadError(f"cannot load grammar module {grammar_module!r}: {exc}") from exc
36
+ dist = grammar_module.replace("_", "-")
37
+ try:
38
+ version = importlib.metadata.version(dist)
39
+ except importlib.metadata.PackageNotFoundError:
40
+ version = "unknown"
41
+ # version -> abi_version shim (abi_version since py-tree-sitter 0.25)
42
+ abi: int | None = getattr(lang, "abi_version", None)
43
+ if abi is None: # pragma: no cover - older binding shim
44
+ abi = int(getattr(lang, "version", 0))
45
+ return lang, version, int(abi)
46
+
47
+
48
+ def make_parser(language: Language) -> Parser:
49
+ return Parser(language)
50
+
51
+
52
+ def walk(tree: Tree) -> Iterator[tuple[Node, int]]:
53
+ """Cursor-based preorder ``(node, depth)`` — O(1) memory, no recursion limit,
54
+ deterministic order."""
55
+ cursor = tree.walk()
56
+ depth = 0
57
+ while True:
58
+ node = cursor.node
59
+ assert node is not None # a fresh walk cursor always sits on a node
60
+ yield node, depth
61
+ if cursor.goto_first_child():
62
+ depth += 1
63
+ continue
64
+ while True:
65
+ if cursor.goto_next_sibling():
66
+ break
67
+ if not cursor.goto_parent():
68
+ return
69
+ depth -= 1
70
+
71
+
72
+ def is_opaque(node: Node) -> bool:
73
+ """ERROR/MISSING subtrees are opaque to all measurement (CORE-ALL-0002)."""
74
+ return node.type == "ERROR" or node.is_missing
75
+
76
+
77
+ def leaves(tree: Tree, atomic_types: frozenset[str]) -> Iterator[Node]:
78
+ """Terminal-token stream: descend to leaves, but consume node types in
79
+ ``atomic_types`` (e.g. Python ``string`` with its start/content/end
80
+ children) as single opaque tokens (TOK-PY-0002 / TOK-JAVA-0001).
81
+
82
+ ERROR/MISSING subtrees yield nothing and are not descended into
83
+ (CORE-ALL-0002): their tokens must not feed Halstead/LOC or (on clean
84
+ parses) BW. On parse errors, BW token-family features instead use
85
+ :func:`leaves_lexical`, which descends into ERROR subtrees (BW-ALL-0007).
86
+ """
87
+ cursor = tree.walk()
88
+ while True:
89
+ node = cursor.node
90
+ assert node is not None # a fresh walk cursor always sits on a node
91
+ descend = False
92
+ if not is_opaque(node):
93
+ if node.child_count == 0 or node.type in atomic_types:
94
+ yield node
95
+ else:
96
+ descend = True
97
+ if descend and cursor.goto_first_child():
98
+ continue
99
+ while True:
100
+ if cursor.goto_next_sibling():
101
+ break
102
+ if not cursor.goto_parent():
103
+ return
104
+
105
+
106
+ def leaves_lexical(tree: Tree, atomic_types: frozenset[str]) -> Iterator[Node]:
107
+ """Full lexical leaf stream: like :func:`leaves`, but DESCENDS into ERROR
108
+ subtrees, yielding the tokens tree-sitter recovered inside them — the BW
109
+ construct is lexical and must see fragment tokens (BW-ALL-0007). MISSING
110
+ nodes are zero-width fabrications and still yield nothing; a childless
111
+ ERROR node (raw untokenized bytes) also yields nothing."""
112
+ cursor = tree.walk()
113
+ while True:
114
+ node = cursor.node
115
+ assert node is not None # a fresh walk cursor always sits on a node
116
+ descend = False
117
+ if not node.is_missing:
118
+ if node.type == "ERROR":
119
+ descend = node.child_count > 0
120
+ elif node.child_count == 0 or node.type in atomic_types:
121
+ yield node
122
+ else:
123
+ descend = True
124
+ if descend and cursor.goto_first_child():
125
+ continue
126
+ while True:
127
+ if cursor.goto_next_sibling():
128
+ break
129
+ if not cursor.goto_parent():
130
+ return
131
+
132
+
133
+ def node_kinds(language: Language) -> frozenset[str]:
134
+ """Grammar introspection: every node-kind string of the COMPILED grammar.
135
+
136
+ Used by tests/test_grammar_integrity.py to validate adapter tables and
137
+ ruling node_types against the actually-installed grammar, so a grammar node
138
+ rename fails loudly instead of silently zeroing metrics.
139
+ """
140
+ count: int = language.node_kind_count
141
+ kinds = set()
142
+ for i in range(count):
143
+ kind = language.node_kind_for_id(i)
144
+ if kind is not None:
145
+ kinds.add(kind)
146
+ return frozenset(kinds)
147
+
148
+
149
+ def count_error_nodes(tree: Tree) -> int:
150
+ """Number of ERROR/MISSING nodes (parse-error policy CORE-ALL-0002)."""
151
+ n = 0
152
+ for node, _ in walk(tree):
153
+ if node.type == "ERROR" or node.is_missing:
154
+ n += 1
155
+ return n
156
+
157
+
158
+ def node_text(node: Node, source: bytes) -> str:
159
+ return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
160
+
161
+
162
+ def field_name_of(node: Node) -> str | None:
163
+ """The field name this node occupies in its parent, if any."""
164
+ parent = node.parent
165
+ if parent is None:
166
+ return None
167
+ # tree-sitter exposes this via the parent's field iteration; the portable
168
+ # way across binding versions is child_by_field_name comparison.
169
+ for field in ("alternative", "consequence", "condition", "operator", "body", "name"):
170
+ try:
171
+ if parent.child_by_field_name(field) == node:
172
+ return field
173
+ except Exception: # pragma: no cover - defensive against binding churn
174
+ return None
175
+ return None
176
+
177
+
178
+ def language_of_parser(parser: Parser) -> Any: # pragma: no cover - debugging aid
179
+ return parser.language
@@ -0,0 +1,45 @@
1
+ """Grammar loading and the calibration check (ARCHITECTURE.md §10).
2
+
3
+ A deviating installed grammar version never refuses to run — it is stamped
4
+ ``GrammarInfo.validated=False`` and every report carries an
5
+ ``unvalidated-grammar`` diagnostic. Run and label, never refuse, never silently
6
+ trust.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from functools import cache, lru_cache
13
+ from importlib import resources
14
+
15
+ from codecaliper.model import GrammarInfo
16
+ from codecaliper.syntax import _treesitter as ts
17
+
18
+ if sys.version_info >= (3, 11):
19
+ import tomllib
20
+ else: # pragma: no cover - py3.10 fallback
21
+ import tomli as tomllib
22
+
23
+ _GRAMMAR_MODULES = {"python": "tree_sitter_python", "java": "tree_sitter_java"}
24
+
25
+
26
+ @lru_cache(maxsize=1)
27
+ def validated_versions() -> dict[str, str]:
28
+ ref = resources.files("codecaliper.spec") / "validated_grammars.toml"
29
+ with ref.open("rb") as f:
30
+ return dict(tomllib.load(f)["grammars"])
31
+
32
+
33
+ @cache
34
+ def load(language_name: str) -> tuple[ts.Language, ts.Parser, GrammarInfo]:
35
+ module = _GRAMMAR_MODULES[language_name]
36
+ lang, version, abi = ts.load_language(module)
37
+ parser = ts.make_parser(lang)
38
+ info = GrammarInfo(
39
+ language=language_name,
40
+ package=module.replace("_", "-"),
41
+ version=version,
42
+ abi_version=abi,
43
+ validated=(validated_versions().get(language_name) == version),
44
+ )
45
+ return lang, parser, info