code-constraints 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.
- code_constraints/__init__.py +1 -0
- code_constraints/cli/__init__.py +0 -0
- code_constraints/cli/__main__.py +1555 -0
- code_constraints/cli/_assets/agents/cdec-architect.md +468 -0
- code_constraints/cli/_assets/agents/oop-refactor-architect.md +317 -0
- code_constraints/cli/_assets/shims/csharp/CodeConstraintsRules.cs +94 -0
- code_constraints/cli/_assets/shims/julia/CdecRules.jl +129 -0
- code_constraints/cli/_assets/shims/lua/cdec_rules.lua +92 -0
- code_constraints/cli/_assets/shims/odin/cdec_rules.odin +67 -0
- code_constraints/cli/_assets/shims/python/cdec_rules.py +94 -0
- code_constraints/cli/_assets/skills/cdec-architecture-loop/SKILL.md +152 -0
- code_constraints/cli/depstamp.py +118 -0
- code_constraints/cli/detect.py +77 -0
- code_constraints/cli/interactive.py +304 -0
- code_constraints/cli/scaffold.py +602 -0
- code_constraints/cli/update.py +157 -0
- code_constraints/core/__init__.py +41 -0
- code_constraints/core/annotations.py +217 -0
- code_constraints/core/associations.py +134 -0
- code_constraints/core/diff.py +302 -0
- code_constraints/core/editor_io.py +280 -0
- code_constraints/core/graph_model.py +681 -0
- code_constraints/core/keys.py +105 -0
- code_constraints/core/model.py +294 -0
- code_constraints/core/model_io.py +65 -0
- code_constraints/core/receivers.py +34 -0
- code_constraints/core/rules.py +177 -0
- code_constraints/core/rulesdoc.py +208 -0
- code_constraints/core/tags.py +114 -0
- code_constraints/core/ts_fingerprint.py +88 -0
- code_constraints/core/xmi_reader.py +358 -0
- code_constraints/core/xmi_writer.py +373 -0
- code_constraints/csharp/__init__.py +3 -0
- code_constraints/csharp/activity.py +250 -0
- code_constraints/csharp/conformance.py +331 -0
- code_constraints/csharp/fingerprint.py +274 -0
- code_constraints/csharp/parser.py +436 -0
- code_constraints/csharp/rules_extract.py +78 -0
- code_constraints/csharp/sequence.py +295 -0
- code_constraints/enforce/__init__.py +15 -0
- code_constraints/enforce/engine.py +122 -0
- code_constraints/enforce/model.py +74 -0
- code_constraints/julia/__init__.py +5 -0
- code_constraints/julia/conformance.py +282 -0
- code_constraints/julia/fingerprint.py +226 -0
- code_constraints/julia/parser.py +523 -0
- code_constraints/julia/rules_extract.py +216 -0
- code_constraints/lint/__init__.py +10 -0
- code_constraints/lint/baseline.py +96 -0
- code_constraints/lint/config.py +239 -0
- code_constraints/lint/engine.py +179 -0
- code_constraints/lint/pipeline.py +108 -0
- code_constraints/lint/report.py +151 -0
- code_constraints/lint/rules/__init__.py +50 -0
- code_constraints/lint/rules/base.py +200 -0
- code_constraints/lint/rules/cyclic_package_dependencies.py +69 -0
- code_constraints/lint/rules/dangling_classes.py +98 -0
- code_constraints/lint/rules/forbidden_package_references.py +47 -0
- code_constraints/lint/rules/forbidden_references.py +48 -0
- code_constraints/lint/rules/frozen_members.py +67 -0
- code_constraints/lint/rules/frozen_rules.py +105 -0
- code_constraints/lint/rules/implementation_locks.py +156 -0
- code_constraints/lint/rules/layer_dependencies.py +92 -0
- code_constraints/lint/rules/max_class_fanout.py +41 -0
- code_constraints/lint/rules/no_new_classes.py +27 -0
- code_constraints/lint/rules/no_removed_classes.py +27 -0
- code_constraints/lint/rules/reference_architecture.py +111 -0
- code_constraints/lint/rules/subclass_naming.py +71 -0
- code_constraints/lint/rules/tag_conformance.py +76 -0
- code_constraints/lock/__init__.py +73 -0
- code_constraints/lock/engine.py +395 -0
- code_constraints/lock/model.py +235 -0
- code_constraints/lock/store.py +144 -0
- code_constraints/lua/__init__.py +5 -0
- code_constraints/lua/conformance.py +239 -0
- code_constraints/lua/fingerprint.py +252 -0
- code_constraints/lua/parser.py +500 -0
- code_constraints/lua/rules_extract.py +55 -0
- code_constraints/mcp/__init__.py +20 -0
- code_constraints/mcp/__main__.py +73 -0
- code_constraints/mcp/server.py +1203 -0
- code_constraints/odin/__init__.py +5 -0
- code_constraints/odin/conformance.py +244 -0
- code_constraints/odin/fingerprint.py +159 -0
- code_constraints/odin/parser.py +471 -0
- code_constraints/odin/rules_extract.py +38 -0
- code_constraints/python/__init__.py +3 -0
- code_constraints/python/activity.py +278 -0
- code_constraints/python/conformance.py +249 -0
- code_constraints/python/fingerprint.py +231 -0
- code_constraints/python/parser.py +330 -0
- code_constraints/python/rules_extract.py +83 -0
- code_constraints/python/sequence.py +257 -0
- code_constraints/reference/__init__.py +15 -0
- code_constraints/reference/compare.py +356 -0
- code_constraints/reference/report.py +38 -0
- code_constraints/svelte/__init__.py +3 -0
- code_constraints/svelte/parser.py +523 -0
- code_constraints/typescript/__init__.py +3 -0
- code_constraints/typescript/parser.py +590 -0
- code_constraints/waivers/__init__.py +89 -0
- code_constraints/waivers/collect.py +167 -0
- code_constraints/waivers/model.py +90 -0
- code_constraints/waivers/ops.py +150 -0
- code_constraints/waivers/review.py +156 -0
- code_constraints/waivers/store.py +300 -0
- code_constraints/web/__init__.py +0 -0
- code_constraints/web/_static/assets/index-3ivBsYY4.css +1 -0
- code_constraints/web/_static/assets/index-BTzTqGFp.js +9 -0
- code_constraints/web/_static/index.html +13 -0
- code_constraints/web/app.py +1076 -0
- code_constraints-0.1.0.dist-info/METADATA +663 -0
- code_constraints-0.1.0.dist-info/RECORD +116 -0
- code_constraints-0.1.0.dist-info/WHEEL +4 -0
- code_constraints-0.1.0.dist-info/entry_points.txt +3 -0
- code_constraints-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""`.cdec/rules.yaml` — the one file a project commits.
|
|
2
|
+
|
|
3
|
+
Everything the tool needs to gate a codebase lives here: the project settings,
|
|
4
|
+
the rules that are enforced, the exceptions that were granted, and the digests
|
|
5
|
+
of frozen implementations. One file, one diff to review, one thing to put behind
|
|
6
|
+
CODEOWNERS.
|
|
7
|
+
|
|
8
|
+
That creates a problem this module exists to solve. The `rules:` section is
|
|
9
|
+
hand-written and carries the explanations that make a failed build teach
|
|
10
|
+
something; the `exceptions:` and `locks:` sections are written by the tool. A
|
|
11
|
+
naive ``yaml.safe_dump`` of the whole document would silently delete every
|
|
12
|
+
comment and reflow every ``message: |`` block the first time anyone ran
|
|
13
|
+
``cdec check --automatic-exceptions``.
|
|
14
|
+
|
|
15
|
+
So writes are *surgical*. The tool-written sections live at the end of the file
|
|
16
|
+
under a marker line; a write keeps every byte above the marker verbatim and
|
|
17
|
+
regenerates only what is below it. Reads are ordinary ``yaml.safe_load`` over
|
|
18
|
+
the whole document, so the split is invisible to everything else.
|
|
19
|
+
|
|
20
|
+
Shape::
|
|
21
|
+
|
|
22
|
+
language: python
|
|
23
|
+
source: src
|
|
24
|
+
|
|
25
|
+
rules:
|
|
26
|
+
- id: catalog-is-a-leaf
|
|
27
|
+
type: forbidden-package-references
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
# >>> cdec: managed section ...
|
|
31
|
+
exceptions:
|
|
32
|
+
- key: V-1A2B3C4D
|
|
33
|
+
...
|
|
34
|
+
locks:
|
|
35
|
+
- target: orders.Receipt.formatted
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
Deliberately engine-agnostic: this module deals in plain mappings and imports
|
|
39
|
+
nothing from `lint`, `enforce`, `lock` or `waivers`, so the engines that write
|
|
40
|
+
into the file stay decoupled from each other.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import re
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
import yaml
|
|
50
|
+
|
|
51
|
+
RULES_FILENAME = "rules.yaml"
|
|
52
|
+
|
|
53
|
+
#: Top-level keys owned by the tool. Everything else in the file is the user's.
|
|
54
|
+
MANAGED_KEYS: tuple[str, ...] = ("exceptions", "locks")
|
|
55
|
+
|
|
56
|
+
MANAGED_MARKER = (
|
|
57
|
+
"# >>> cdec: managed section — rewritten by `cdec check --automatic-exceptions`\n"
|
|
58
|
+
"# >>> and by `cdec exceptions ...`. Edit the rules above this line, not below it.\n"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# A top-level YAML key: no leading whitespace, an identifier, a colon.
|
|
62
|
+
_TOP_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*)\s*:")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RulesFileError(ValueError):
|
|
66
|
+
"""Raised when `rules.yaml` exists but can't be interpreted."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_document(path: Path) -> dict[str, Any]:
|
|
70
|
+
"""Read the whole file as a mapping. A missing file is an empty document."""
|
|
71
|
+
if not path.is_file():
|
|
72
|
+
return {}
|
|
73
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
74
|
+
try:
|
|
75
|
+
raw = yaml.safe_load(fh) or {}
|
|
76
|
+
except yaml.YAMLError as exc:
|
|
77
|
+
raise RulesFileError(f"{path}: not valid YAML ({exc})") from exc
|
|
78
|
+
if not isinstance(raw, dict):
|
|
79
|
+
raise RulesFileError(f"{path}: top-level must be a mapping")
|
|
80
|
+
return raw
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_section(path: Path, key: str) -> Any:
|
|
84
|
+
"""One top-level section, or None when absent."""
|
|
85
|
+
return load_document(path).get(key)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write_sections(path: Path, sections: dict[str, Any]) -> None:
|
|
89
|
+
"""Replace the named tool-owned sections, preserving everything else byte
|
|
90
|
+
for byte.
|
|
91
|
+
|
|
92
|
+
Only the keys in `sections` are touched — writing `exceptions` leaves a
|
|
93
|
+
`locks` section exactly as it was, so the two engines can write the same
|
|
94
|
+
file without stepping on each other.
|
|
95
|
+
"""
|
|
96
|
+
unknown = set(sections) - set(MANAGED_KEYS)
|
|
97
|
+
if unknown:
|
|
98
|
+
raise RulesFileError(
|
|
99
|
+
f"refusing to write non-managed section(s) {sorted(unknown)} into {path}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
103
|
+
prefix, existing = _split(text)
|
|
104
|
+
|
|
105
|
+
merged = dict(existing)
|
|
106
|
+
for key, value in sections.items():
|
|
107
|
+
if value:
|
|
108
|
+
merged[key] = value
|
|
109
|
+
else:
|
|
110
|
+
merged.pop(key, None)
|
|
111
|
+
|
|
112
|
+
body = ""
|
|
113
|
+
for key in MANAGED_KEYS:
|
|
114
|
+
if key in merged:
|
|
115
|
+
body += yaml.safe_dump(
|
|
116
|
+
{key: merged[key]}, sort_keys=False, default_flow_style=False,
|
|
117
|
+
allow_unicode=True, width=100000,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if prefix and not prefix.endswith("\n"):
|
|
121
|
+
prefix += "\n"
|
|
122
|
+
out = prefix
|
|
123
|
+
if body:
|
|
124
|
+
if out and not out.endswith("\n\n"):
|
|
125
|
+
out += "\n"
|
|
126
|
+
out += MANAGED_MARKER + body
|
|
127
|
+
elif not out:
|
|
128
|
+
out = ""
|
|
129
|
+
|
|
130
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
path.write_text(out, encoding="utf-8")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------- internals ----------
|
|
135
|
+
|
|
136
|
+
def _split(text: str) -> tuple[str, dict[str, Any]]:
|
|
137
|
+
"""Split the document into (verbatim prefix, parsed managed sections).
|
|
138
|
+
|
|
139
|
+
The prefix is everything the user owns. Managed sections are removed from it
|
|
140
|
+
wherever they appear — normally below the marker, but a hand-placed
|
|
141
|
+
`exceptions:` higher up is lifted too, so a write can never produce a
|
|
142
|
+
duplicate top-level key.
|
|
143
|
+
"""
|
|
144
|
+
lines = text.splitlines(keepends=True)
|
|
145
|
+
spans = _top_level_spans(lines)
|
|
146
|
+
|
|
147
|
+
managed_text = "".join(
|
|
148
|
+
"".join(lines[start:end])
|
|
149
|
+
for key, start, end in spans
|
|
150
|
+
if key in MANAGED_KEYS
|
|
151
|
+
)
|
|
152
|
+
drop: set[int] = set()
|
|
153
|
+
for key, start, end in spans:
|
|
154
|
+
if key in MANAGED_KEYS:
|
|
155
|
+
drop.update(range(start, end))
|
|
156
|
+
|
|
157
|
+
prefix_lines = [line for i, line in enumerate(lines) if i not in drop]
|
|
158
|
+
prefix = "".join(prefix_lines)
|
|
159
|
+
# The marker only describes the block below it; without one it is noise.
|
|
160
|
+
prefix = prefix.replace(MANAGED_MARKER, "")
|
|
161
|
+
for marker_line in MANAGED_MARKER.splitlines(keepends=True):
|
|
162
|
+
prefix = prefix.replace(marker_line, "")
|
|
163
|
+
prefix = prefix.rstrip("\n")
|
|
164
|
+
if prefix:
|
|
165
|
+
prefix += "\n"
|
|
166
|
+
|
|
167
|
+
if not managed_text.strip():
|
|
168
|
+
return prefix, {}
|
|
169
|
+
try:
|
|
170
|
+
parsed = yaml.safe_load(managed_text) or {}
|
|
171
|
+
except yaml.YAMLError:
|
|
172
|
+
# Unparseable managed text is regenerated from scratch rather than
|
|
173
|
+
# merged; the caller is handing us the authoritative content anyway.
|
|
174
|
+
return prefix, {}
|
|
175
|
+
return prefix, parsed if isinstance(parsed, dict) else {}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _top_level_spans(lines: list[str]) -> list[tuple[str, int, int]]:
|
|
179
|
+
"""(key, start_index, end_index) for every top-level mapping key.
|
|
180
|
+
|
|
181
|
+
A span runs from the key line to just before the next top-level key (or
|
|
182
|
+
EOF), so it carries the key's whole nested block. Comment lines immediately
|
|
183
|
+
above a key belong to that key, so they travel with it.
|
|
184
|
+
"""
|
|
185
|
+
starts: list[tuple[str, int]] = []
|
|
186
|
+
for i, line in enumerate(lines):
|
|
187
|
+
match = _TOP_KEY_RE.match(line)
|
|
188
|
+
if match is None:
|
|
189
|
+
continue
|
|
190
|
+
starts.append((match.group(1), _comment_block_start(lines, i)))
|
|
191
|
+
|
|
192
|
+
spans: list[tuple[str, int, int]] = []
|
|
193
|
+
for pos, (key, start) in enumerate(starts):
|
|
194
|
+
end = starts[pos + 1][1] if pos + 1 < len(starts) else len(lines)
|
|
195
|
+
spans.append((key, start, end))
|
|
196
|
+
return spans
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _comment_block_start(lines: list[str], key_index: int) -> int:
|
|
200
|
+
"""Index of the first line of the comment block attached to `key_index`."""
|
|
201
|
+
i = key_index
|
|
202
|
+
while i > 0:
|
|
203
|
+
previous = lines[i - 1].strip()
|
|
204
|
+
if previous.startswith("#"):
|
|
205
|
+
i -= 1
|
|
206
|
+
continue
|
|
207
|
+
break
|
|
208
|
+
return i
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Parse XML-style comment tags from source code.
|
|
2
|
+
|
|
3
|
+
Recognised forms (after the comment prefix is stripped):
|
|
4
|
+
|
|
5
|
+
<uml-class />
|
|
6
|
+
<uml-activity name="checkout" granularity="control-flow">
|
|
7
|
+
</uml-activity>
|
|
8
|
+
<uml-sequence name="login" root="Handle">
|
|
9
|
+
</uml-sequence>
|
|
10
|
+
|
|
11
|
+
The caller provides the comment prefix (e.g. `//` for C#, `#` for Python).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import xml.etree.ElementTree as ET
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
TagKind = Literal["uml-class", "uml-activity", "uml-sequence"]
|
|
22
|
+
_VALID_KINDS: set[str] = {"uml-class", "uml-activity", "uml-sequence"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class TagInstance:
|
|
27
|
+
"""A matched tag span. `start_line` and `end_line` are 1-indexed; the
|
|
28
|
+
closed range covers the lines between the opening and closing tags
|
|
29
|
+
(inclusive of the lines containing the tags themselves)."""
|
|
30
|
+
kind: TagKind
|
|
31
|
+
name: str
|
|
32
|
+
attributes: dict[str, str] = field(default_factory=dict)
|
|
33
|
+
start_line: int = 0
|
|
34
|
+
end_line: int = 0
|
|
35
|
+
self_closed: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_TAG_LINE_RE = re.compile(r"<\s*/?\s*uml-[a-zA-Z-]+\b[^>]*?/?\s*>")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_tags(source: str, comment_prefix: str) -> list[TagInstance]:
|
|
42
|
+
"""Scan `source` (full file text) and return tag spans.
|
|
43
|
+
|
|
44
|
+
Unbalanced tags (open without close, or vice versa) are skipped silently;
|
|
45
|
+
the parser is intentionally forgiving so a malformed comment never breaks
|
|
46
|
+
the rest of the diagram generation.
|
|
47
|
+
"""
|
|
48
|
+
prefix = comment_prefix.strip()
|
|
49
|
+
open_stack: list[tuple[str, dict[str, str], int]] = []
|
|
50
|
+
result: list[TagInstance] = []
|
|
51
|
+
|
|
52
|
+
for lineno, line in enumerate(source.splitlines(), start=1):
|
|
53
|
+
stripped = line.strip()
|
|
54
|
+
if not stripped.startswith(prefix):
|
|
55
|
+
continue
|
|
56
|
+
rest = stripped[len(prefix) :].strip()
|
|
57
|
+
match = _TAG_LINE_RE.search(rest)
|
|
58
|
+
if not match:
|
|
59
|
+
continue
|
|
60
|
+
tag_text = match.group(0)
|
|
61
|
+
|
|
62
|
+
if tag_text.startswith("</"):
|
|
63
|
+
kind = _extract_kind(tag_text)
|
|
64
|
+
if kind not in _VALID_KINDS:
|
|
65
|
+
continue
|
|
66
|
+
if open_stack and open_stack[-1][0] == kind:
|
|
67
|
+
opened_kind, attrs, opened_line = open_stack.pop()
|
|
68
|
+
result.append(
|
|
69
|
+
TagInstance(
|
|
70
|
+
kind=opened_kind, # type: ignore[arg-type]
|
|
71
|
+
name=attrs.get("name", ""),
|
|
72
|
+
attributes=attrs,
|
|
73
|
+
start_line=opened_line,
|
|
74
|
+
end_line=lineno,
|
|
75
|
+
self_closed=False,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
is_self_closing = tag_text.rstrip().endswith("/>")
|
|
81
|
+
# ET requires balanced XML — rewrite an open tag as self-closing so
|
|
82
|
+
# we can reuse it to parse attributes.
|
|
83
|
+
parse_text = tag_text if is_self_closing else tag_text.rstrip()[:-1] + "/>"
|
|
84
|
+
try:
|
|
85
|
+
element = ET.fromstring(parse_text)
|
|
86
|
+
except ET.ParseError:
|
|
87
|
+
continue
|
|
88
|
+
kind = element.tag
|
|
89
|
+
if kind not in _VALID_KINDS:
|
|
90
|
+
continue
|
|
91
|
+
attrs = dict(element.attrib)
|
|
92
|
+
if is_self_closing:
|
|
93
|
+
result.append(
|
|
94
|
+
TagInstance(
|
|
95
|
+
kind=kind, # type: ignore[arg-type]
|
|
96
|
+
name=attrs.get("name", ""),
|
|
97
|
+
attributes=attrs,
|
|
98
|
+
start_line=lineno,
|
|
99
|
+
end_line=lineno,
|
|
100
|
+
self_closed=True,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
open_stack.append((kind, attrs, lineno))
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _extract_kind(tag_text: str) -> str:
|
|
110
|
+
"""Pull the bare tag name out of `</uml-activity>` etc."""
|
|
111
|
+
cleaned = tag_text.strip().lstrip("<").rstrip(">").lstrip("/").rstrip("/").strip()
|
|
112
|
+
if " " in cleaned:
|
|
113
|
+
cleaned = cleaned.split(" ", 1)[0]
|
|
114
|
+
return cleaned
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Canonical AST serialisation for tree-sitter-backed lock fingerprints.
|
|
2
|
+
|
|
3
|
+
The shared half of Engine C (`cdec lock`) for every tree-sitter language. It
|
|
4
|
+
answers only "what string represents this subtree"; each language's
|
|
5
|
+
`fingerprint.py` owns discovery, target naming, and which nodes to drop.
|
|
6
|
+
|
|
7
|
+
The invariants a fingerprinter must preserve — the same ones documented on
|
|
8
|
+
`code_constraints.csharp.fingerprint`, which predates this module and keeps its
|
|
9
|
+
own copy:
|
|
10
|
+
|
|
11
|
+
* **Anonymous children are walked.** Operators and punctuation are anonymous
|
|
12
|
+
tokens, so skipping them would hash `a + b` and `a - b` identically.
|
|
13
|
+
* **Whitespace never appears.** tree-sitter emits no nodes for indentation or
|
|
14
|
+
newlines, so a locked body survives reformatting and relocation for free.
|
|
15
|
+
* **Comments are dropped** unless the caller keeps them, and the rule *tag*
|
|
16
|
+
itself is always dropped — applying or removing a lock must not change the
|
|
17
|
+
digest of the body it guards.
|
|
18
|
+
* **Changing this serialiser must bump the caller's algo id.** A mismatch then
|
|
19
|
+
surfaces as an `algo-mismatch` violation ("re-baseline") rather than as a
|
|
20
|
+
false "implementation changed".
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from hashlib import sha256
|
|
26
|
+
from typing import Callable, Sequence
|
|
27
|
+
|
|
28
|
+
from tree_sitter import Node
|
|
29
|
+
|
|
30
|
+
# Returns True for a node that must not contribute to the digest. Takes the
|
|
31
|
+
# source alongside the node because a group's members can come from different
|
|
32
|
+
# files (Odin and Julia declare a type's operations at package scope, so two
|
|
33
|
+
# same-named members need not share a file).
|
|
34
|
+
DropFn = Callable[[Node, bytes], bool]
|
|
35
|
+
|
|
36
|
+
# Extra text folded into a member's digest beyond its serialised subtree. Lets a
|
|
37
|
+
# language account for something outside the node it digests — Julia uses it to
|
|
38
|
+
# keep non-rule macro wrappers significant while digesting the unwrapped
|
|
39
|
+
# definition. Return "" to add nothing.
|
|
40
|
+
ExtraFn = Callable[[Node, bytes], str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def canonical(node: Node, source: bytes, drop: DropFn) -> str:
|
|
44
|
+
"""Serialise `node` to a canonical, position-independent string."""
|
|
45
|
+
if drop(node, source):
|
|
46
|
+
return ""
|
|
47
|
+
if node.child_count == 0:
|
|
48
|
+
text = _text(node, source).strip()
|
|
49
|
+
return f"({node.type} {text!r})" if node.is_named else f"({text!r})"
|
|
50
|
+
parts = [p for p in (canonical(c, source, drop) for c in node.children) if p]
|
|
51
|
+
if not parts:
|
|
52
|
+
# Every child was dropped; keep the node's own identity so an emptied
|
|
53
|
+
# wrapper still differs from the wrapper being absent.
|
|
54
|
+
return f"({node.type})"
|
|
55
|
+
return f"({node.type} {' '.join(parts)})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def digest_members(
|
|
59
|
+
members: Sequence[tuple[Node, bytes]], drop: DropFn, extra: ExtraFn | None = None
|
|
60
|
+
) -> str:
|
|
61
|
+
"""Digest a group of same-named members as one target.
|
|
62
|
+
|
|
63
|
+
Overloads, `@property`/setter pairs, and multiple-dispatch methods collapse
|
|
64
|
+
into a single digest over their sorted member digests, so adding an overload
|
|
65
|
+
to a locked name is a violation in its own right and no ordinal
|
|
66
|
+
disambiguator is needed. Sorting also makes the digest independent of the
|
|
67
|
+
order the files happened to be walked in.
|
|
68
|
+
"""
|
|
69
|
+
digests = sorted(
|
|
70
|
+
sha256(
|
|
71
|
+
(canonical(node, source, drop) + _suffix(extra, node, source)).encode("utf-8")
|
|
72
|
+
).hexdigest()
|
|
73
|
+
for node, source in members
|
|
74
|
+
)
|
|
75
|
+
if len(digests) == 1:
|
|
76
|
+
return digests[0]
|
|
77
|
+
return sha256(("group:" + "|".join(digests)).encode("utf-8")).hexdigest()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _suffix(extra: ExtraFn | None, node: Node, source: bytes) -> str:
|
|
81
|
+
if extra is None:
|
|
82
|
+
return ""
|
|
83
|
+
text = extra(node, source)
|
|
84
|
+
return f"|extra:{text}" if text else ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _text(node: Node, source: bytes) -> str:
|
|
88
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|