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,331 @@
|
|
|
1
|
+
"""Body-level conformance analysis for C# (`cdec enforce`, Engine B).
|
|
2
|
+
|
|
3
|
+
Re-parses C# source with tree-sitter and inspects method/constructor bodies for
|
|
4
|
+
violations of architectural-rule tags. Reuses the shared recognizer in
|
|
5
|
+
`rules_extract` so the set of "what is a rule" stays identical to the UML parser.
|
|
6
|
+
|
|
7
|
+
Unlike the Python analyzer, construction detection is precise: it keys off the
|
|
8
|
+
`object_creation_expression` / `array_creation_expression` node kinds rather than
|
|
9
|
+
guessing from the callee name. Field reassignment (`immutable`) is matched on
|
|
10
|
+
`this.<field>` member access and bare assignment to a known field outside the
|
|
11
|
+
constructor.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import tree_sitter_c_sharp
|
|
20
|
+
from tree_sitter import Language, Node, Parser
|
|
21
|
+
|
|
22
|
+
from code_constraints.core.model import Project
|
|
23
|
+
from code_constraints.csharp.rules_extract import extract_rules, using_has_shim
|
|
24
|
+
from code_constraints.enforce.model import Finding
|
|
25
|
+
|
|
26
|
+
_LANG = Language(tree_sitter_c_sharp.language())
|
|
27
|
+
_PARSER = Parser(_LANG)
|
|
28
|
+
|
|
29
|
+
CTOR_RULE = "no-instantiation"
|
|
30
|
+
FACTORY_RULE = "factory"
|
|
31
|
+
IMMUTABLE_RULE = "immutable"
|
|
32
|
+
|
|
33
|
+
_CLASS_LIKE = {
|
|
34
|
+
"class_declaration",
|
|
35
|
+
"interface_declaration",
|
|
36
|
+
"struct_declaration",
|
|
37
|
+
"record_declaration",
|
|
38
|
+
"record_struct_declaration",
|
|
39
|
+
}
|
|
40
|
+
_SKIP_DIR_NAMES = {"bin", "obj", ".git", "packages", "TestResults"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def analyze(root: Path, project: Project) -> list[Finding]:
|
|
44
|
+
project_classes = {cls.name for cls in project.iter_classes()}
|
|
45
|
+
factory_index = _factory_index(project)
|
|
46
|
+
|
|
47
|
+
findings: list[Finding] = []
|
|
48
|
+
for cs_file in sorted(root.rglob("*.cs")):
|
|
49
|
+
if any(part in _SKIP_DIR_NAMES for part in cs_file.parts):
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
source = cs_file.read_bytes()
|
|
53
|
+
except OSError:
|
|
54
|
+
continue
|
|
55
|
+
tree = _PARSER.parse(source)
|
|
56
|
+
rel = cs_file.relative_to(root).as_posix()
|
|
57
|
+
shim = using_has_shim(tree.root_node, source)
|
|
58
|
+
for cls_node, qn, short_name in _iter_classes(tree.root_node, source):
|
|
59
|
+
_analyze_class(
|
|
60
|
+
cls_node, qn, short_name, rel, source, shim,
|
|
61
|
+
project_classes, factory_index, findings,
|
|
62
|
+
)
|
|
63
|
+
return findings
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _analyze_class(
|
|
67
|
+
cls_node: Node,
|
|
68
|
+
class_qn: str,
|
|
69
|
+
class_name: str,
|
|
70
|
+
file: str,
|
|
71
|
+
source: bytes,
|
|
72
|
+
shim: bool,
|
|
73
|
+
project_classes: set[str],
|
|
74
|
+
factory_index: dict[str, set[str]],
|
|
75
|
+
out: list[Finding],
|
|
76
|
+
) -> None:
|
|
77
|
+
crules = _rule_map(cls_node, source, shim)
|
|
78
|
+
class_noinst = _allow_set(crules[CTOR_RULE]) if CTOR_RULE in crules else None
|
|
79
|
+
is_immutable = IMMUTABLE_RULE in crules
|
|
80
|
+
field_names = _field_names(cls_node, source)
|
|
81
|
+
|
|
82
|
+
body = cls_node.child_by_field_name("body")
|
|
83
|
+
if body is None:
|
|
84
|
+
return
|
|
85
|
+
for member in body.named_children:
|
|
86
|
+
if member.type not in ("method_declaration", "constructor_declaration"):
|
|
87
|
+
continue
|
|
88
|
+
is_ctor = member.type == "constructor_declaration"
|
|
89
|
+
mrules = _rule_map(member, source, shim)
|
|
90
|
+
if CTOR_RULE in mrules:
|
|
91
|
+
noinst_allow = _allow_set(mrules[CTOR_RULE])
|
|
92
|
+
else:
|
|
93
|
+
noinst_allow = class_noinst
|
|
94
|
+
name = _member_name(member, source)
|
|
95
|
+
body_nodes = _body_nodes(member)
|
|
96
|
+
|
|
97
|
+
for callee, line in _constructions(body_nodes, source):
|
|
98
|
+
is_construction = callee in project_classes or callee[:1].isupper()
|
|
99
|
+
if noinst_allow is not None and is_construction and callee not in noinst_allow:
|
|
100
|
+
out.append(
|
|
101
|
+
Finding(
|
|
102
|
+
rule=CTOR_RULE,
|
|
103
|
+
qualified_name=class_qn,
|
|
104
|
+
message=(
|
|
105
|
+
f"'{class_qn}.{name}' is tagged [NoInstantiation] but "
|
|
106
|
+
f"constructs '{callee}'."
|
|
107
|
+
),
|
|
108
|
+
detail=f"{name}->{callee}",
|
|
109
|
+
file=file,
|
|
110
|
+
line=line,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
designated = factory_index.get(callee)
|
|
114
|
+
if designated is not None and class_name not in designated:
|
|
115
|
+
allowed = ", ".join(sorted(designated)) or "(none)"
|
|
116
|
+
out.append(
|
|
117
|
+
Finding(
|
|
118
|
+
rule=FACTORY_RULE,
|
|
119
|
+
qualified_name=class_qn,
|
|
120
|
+
message=(
|
|
121
|
+
f"'{class_qn}.{name}' constructs '{callee}' outside its "
|
|
122
|
+
f"designated factory ({allowed})."
|
|
123
|
+
),
|
|
124
|
+
detail=f"{name}->{callee}",
|
|
125
|
+
file=file,
|
|
126
|
+
line=line,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if is_immutable and not is_ctor:
|
|
131
|
+
for field_name, line in _field_assignments(body_nodes, field_names, source):
|
|
132
|
+
out.append(
|
|
133
|
+
Finding(
|
|
134
|
+
rule=IMMUTABLE_RULE,
|
|
135
|
+
qualified_name=class_qn,
|
|
136
|
+
message=(
|
|
137
|
+
f"'{class_qn}' is [Immutable] but '{name}' reassigns field "
|
|
138
|
+
f"'{field_name}' outside the constructor."
|
|
139
|
+
),
|
|
140
|
+
detail=f"{name}.{field_name}",
|
|
141
|
+
file=file,
|
|
142
|
+
line=line,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _factory_index(project: Project) -> dict[str, set[str]]:
|
|
148
|
+
"""Created-type name -> set of class names designated to construct it."""
|
|
149
|
+
idx: dict[str, set[str]] = {}
|
|
150
|
+
for cls in project.iter_classes():
|
|
151
|
+
rule_sources = list(cls.rules) + [r for op in cls.operations for r in op.rules]
|
|
152
|
+
for rule in rule_sources:
|
|
153
|
+
if rule.name != FACTORY_RULE:
|
|
154
|
+
continue
|
|
155
|
+
for created in _str_list(_get_kwarg(rule.kwargs, "creates")):
|
|
156
|
+
idx.setdefault(created, set()).add(cls.name)
|
|
157
|
+
return idx
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _iter_classes(root: Node, source: bytes) -> list[tuple[Node, str, str]]:
|
|
161
|
+
results: list[tuple[Node, str, str]] = []
|
|
162
|
+
file_scoped: str | None = None
|
|
163
|
+
for child in root.named_children:
|
|
164
|
+
if child.type == "namespace_declaration":
|
|
165
|
+
_collect_ns(child, "", source, results)
|
|
166
|
+
elif child.type == "file_scoped_namespace_declaration":
|
|
167
|
+
name_node = child.child_by_field_name("name")
|
|
168
|
+
file_scoped = _text(name_node, source) if name_node else "anon"
|
|
169
|
+
elif child.type in _CLASS_LIKE:
|
|
170
|
+
_collect_class(child, file_scoped or "", source, results)
|
|
171
|
+
return results
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _collect_ns(
|
|
175
|
+
node: Node, parent_qn: str, source: bytes, results: list[tuple[Node, str, str]]
|
|
176
|
+
) -> None:
|
|
177
|
+
name_node = node.child_by_field_name("name")
|
|
178
|
+
name = _text(name_node, source) if name_node else "anon"
|
|
179
|
+
qn = name if not parent_qn else f"{parent_qn}.{name}"
|
|
180
|
+
body = node.child_by_field_name("body")
|
|
181
|
+
if body is None:
|
|
182
|
+
return
|
|
183
|
+
for child in body.named_children:
|
|
184
|
+
if child.type == "namespace_declaration":
|
|
185
|
+
_collect_ns(child, qn, source, results)
|
|
186
|
+
elif child.type in _CLASS_LIKE:
|
|
187
|
+
_collect_class(child, qn, source, results)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _collect_class(
|
|
191
|
+
node: Node, package_qn: str, source: bytes, results: list[tuple[Node, str, str]]
|
|
192
|
+
) -> None:
|
|
193
|
+
name_node = node.child_by_field_name("name")
|
|
194
|
+
if name_node is None:
|
|
195
|
+
return
|
|
196
|
+
name = _text(name_node, source)
|
|
197
|
+
qn = f"{package_qn}.{name}" if package_qn else name
|
|
198
|
+
results.append((node, qn, name))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _rule_map(node: Node, source: bytes, shim: bool) -> dict[str, object]:
|
|
202
|
+
return {r.name: r for r in extract_rules(node, source, shim)}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _allow_set(rule) -> set[str]:
|
|
206
|
+
if rule is None:
|
|
207
|
+
return set()
|
|
208
|
+
return _str_list(_get_kwarg(rule.kwargs, "allow"))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _get_kwarg(kwargs: dict[str, str], name: str) -> str:
|
|
212
|
+
"""Case-insensitive kwarg lookup (C# uses PascalCase `Allow`/`Creates`)."""
|
|
213
|
+
for k, v in kwargs.items():
|
|
214
|
+
if k.lower() == name.lower():
|
|
215
|
+
return v
|
|
216
|
+
return ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _str_list(source_text: str) -> set[str]:
|
|
220
|
+
"""Pull quoted strings out of a C# array literal, e.g.
|
|
221
|
+
'new[] { "List" }' -> {"List"}. Tolerant: returns {} on no match."""
|
|
222
|
+
if not source_text:
|
|
223
|
+
return set()
|
|
224
|
+
return set(re.findall(r'"([^"]*)"', source_text))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _member_name(node: Node, source: bytes) -> str:
|
|
228
|
+
name_node = node.child_by_field_name("name")
|
|
229
|
+
return _text(name_node, source) if name_node else "<ctor>"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _body_nodes(method: Node) -> list[Node]:
|
|
233
|
+
"""The block and/or arrow-expression body of a method, excluding its
|
|
234
|
+
attribute_list (so a `new[] { ... }` inside `[NoInstantiation(...)]` is
|
|
235
|
+
never mistaken for a construction)."""
|
|
236
|
+
out: list[Node] = []
|
|
237
|
+
seen: set[int] = set()
|
|
238
|
+
# For arrow-bodied methods the "body" field IS the arrow_expression_clause,
|
|
239
|
+
# so guard against collecting it twice.
|
|
240
|
+
block = method.child_by_field_name("body")
|
|
241
|
+
if block is not None:
|
|
242
|
+
out.append(block)
|
|
243
|
+
seen.add(block.id)
|
|
244
|
+
for c in method.children:
|
|
245
|
+
if c.type == "arrow_expression_clause" and c.id not in seen:
|
|
246
|
+
out.append(c)
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _constructions(body_nodes: list[Node], source: bytes) -> list[tuple[str, int]]:
|
|
251
|
+
out: list[tuple[str, int]] = []
|
|
252
|
+
stack = list(body_nodes)
|
|
253
|
+
while stack:
|
|
254
|
+
n = stack.pop()
|
|
255
|
+
if n.type in ("object_creation_expression", "array_creation_expression"):
|
|
256
|
+
name = _type_name(n.child_by_field_name("type"), source)
|
|
257
|
+
if name:
|
|
258
|
+
out.append((name, n.start_point[0] + 1))
|
|
259
|
+
stack.extend(n.children)
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _type_name(type_node: Node | None, source: bytes) -> str:
|
|
264
|
+
if type_node is None:
|
|
265
|
+
return ""
|
|
266
|
+
text = _text(type_node, source)
|
|
267
|
+
text = text.split("<", 1)[0]
|
|
268
|
+
return text.rsplit(".", 1)[-1].strip()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _field_assignments(
|
|
272
|
+
body_nodes: list[Node], field_names: set[str], source: bytes
|
|
273
|
+
) -> list[tuple[str, int]]:
|
|
274
|
+
out: list[tuple[str, int]] = []
|
|
275
|
+
stack = list(body_nodes)
|
|
276
|
+
while stack:
|
|
277
|
+
n = stack.pop()
|
|
278
|
+
if n.type == "assignment_expression":
|
|
279
|
+
field = _assign_target_field(n.child_by_field_name("left"), field_names, source)
|
|
280
|
+
if field:
|
|
281
|
+
out.append((field, n.start_point[0] + 1))
|
|
282
|
+
stack.extend(n.children)
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _assign_target_field(
|
|
287
|
+
left: Node | None, field_names: set[str], source: bytes
|
|
288
|
+
) -> str | None:
|
|
289
|
+
if left is None:
|
|
290
|
+
return None
|
|
291
|
+
if left.type == "member_access_expression":
|
|
292
|
+
obj = left.child_by_field_name("expression")
|
|
293
|
+
name = left.child_by_field_name("name")
|
|
294
|
+
if obj is not None and obj.type == "this" and name is not None:
|
|
295
|
+
return _text(name, source)
|
|
296
|
+
if left.type == "identifier":
|
|
297
|
+
text = _text(left, source)
|
|
298
|
+
if text in field_names:
|
|
299
|
+
return text
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _field_names(cls_node: Node, source: bytes) -> set[str]:
|
|
304
|
+
names: set[str] = set()
|
|
305
|
+
body = cls_node.child_by_field_name("body")
|
|
306
|
+
if body is None:
|
|
307
|
+
return names
|
|
308
|
+
for member in body.named_children:
|
|
309
|
+
if member.type == "field_declaration":
|
|
310
|
+
decl = next(
|
|
311
|
+
(c for c in member.named_children if c.type == "variable_declaration"),
|
|
312
|
+
None,
|
|
313
|
+
)
|
|
314
|
+
if decl is None:
|
|
315
|
+
continue
|
|
316
|
+
for d in decl.named_children:
|
|
317
|
+
if d.type == "variable_declarator":
|
|
318
|
+
nn = d.child_by_field_name("name")
|
|
319
|
+
if nn is not None:
|
|
320
|
+
names.add(_text(nn, source))
|
|
321
|
+
elif member.type == "property_declaration":
|
|
322
|
+
nn = member.child_by_field_name("name")
|
|
323
|
+
if nn is not None:
|
|
324
|
+
names.add(_text(nn, source))
|
|
325
|
+
return names
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _text(node: Node | None, source: bytes) -> str:
|
|
329
|
+
if node is None:
|
|
330
|
+
return ""
|
|
331
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""AST fingerprinting for C# (`cdec lock`, Engine C).
|
|
2
|
+
|
|
3
|
+
The C# twin of `code_constraints.python.fingerprint`. Digests come from the tree-sitter
|
|
4
|
+
concrete syntax tree, which is already whitespace-insensitive (indentation and
|
|
5
|
+
newlines produce no nodes), so a locked member survives reformatting and
|
|
6
|
+
relocation within its file. Comments *are* nodes here, so they're dropped
|
|
7
|
+
explicitly, as is the `[Locked]` attribute itself.
|
|
8
|
+
|
|
9
|
+
Unlike the Python side we walk anonymous children too: operators and
|
|
10
|
+
punctuation (`+` vs `-`, `==` vs `!=`) are anonymous tokens, and skipping them
|
|
11
|
+
would make `a + b` and `a - b` hash identically.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from hashlib import sha256
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import tree_sitter_c_sharp
|
|
20
|
+
from tree_sitter import Language, Node, Parser
|
|
21
|
+
|
|
22
|
+
from code_constraints.core.rules import CSHARP_SHIM_NAMESPACE, by_csharp_name
|
|
23
|
+
from code_constraints.csharp.rules_extract import extract_rules, using_has_shim
|
|
24
|
+
from code_constraints.lock.model import LOCK_RULE, LockTarget
|
|
25
|
+
|
|
26
|
+
DIGEST_ALGO = "cs-ts/1"
|
|
27
|
+
|
|
28
|
+
_LANG = Language(tree_sitter_c_sharp.language())
|
|
29
|
+
_PARSER = Parser(_LANG)
|
|
30
|
+
|
|
31
|
+
_SKIP_DIR_NAMES = {"bin", "obj", ".git", "packages", "TestResults"}
|
|
32
|
+
|
|
33
|
+
_CLASS_LIKE = {
|
|
34
|
+
"class_declaration",
|
|
35
|
+
"interface_declaration",
|
|
36
|
+
"struct_declaration",
|
|
37
|
+
"record_declaration",
|
|
38
|
+
"record_struct_declaration",
|
|
39
|
+
"enum_declaration",
|
|
40
|
+
}
|
|
41
|
+
_MEMBER_LIKE = {
|
|
42
|
+
"method_declaration",
|
|
43
|
+
"constructor_declaration",
|
|
44
|
+
"destructor_declaration",
|
|
45
|
+
"property_declaration",
|
|
46
|
+
"operator_declaration",
|
|
47
|
+
"indexer_declaration",
|
|
48
|
+
}
|
|
49
|
+
_COMMENT_TYPES = {"comment"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def collect_lockables(
|
|
53
|
+
root: str | Path, *, include_docstrings: bool = False
|
|
54
|
+
) -> list[LockTarget]:
|
|
55
|
+
"""Walk `root` and return every lockable declaration with its digest.
|
|
56
|
+
|
|
57
|
+
`include_docstrings` controls whether `///` XML-doc comments count: they are
|
|
58
|
+
`comment` nodes, so the default (False) drops them along with every other
|
|
59
|
+
comment, and True keeps documentation comments in the digest.
|
|
60
|
+
"""
|
|
61
|
+
root_path = Path(root).resolve()
|
|
62
|
+
out: list[LockTarget] = []
|
|
63
|
+
for cs_file in sorted(root_path.rglob("*.cs")):
|
|
64
|
+
if any(part in _SKIP_DIR_NAMES for part in cs_file.parts):
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
source = cs_file.read_bytes()
|
|
68
|
+
except OSError:
|
|
69
|
+
continue
|
|
70
|
+
tree = _PARSER.parse(source)
|
|
71
|
+
rel = cs_file.relative_to(root_path).as_posix()
|
|
72
|
+
shim = using_has_shim(tree.root_node, source)
|
|
73
|
+
_collect_unit(tree.root_node, source, rel, shim, include_docstrings, out)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _collect_unit(
|
|
78
|
+
root: Node,
|
|
79
|
+
source: bytes,
|
|
80
|
+
file: str,
|
|
81
|
+
shim: bool,
|
|
82
|
+
include_docstrings: bool,
|
|
83
|
+
out: list[LockTarget],
|
|
84
|
+
) -> None:
|
|
85
|
+
file_scoped = ""
|
|
86
|
+
for child in root.named_children:
|
|
87
|
+
if child.type == "namespace_declaration":
|
|
88
|
+
_collect_namespace(child, "", source, file, shim, include_docstrings, out)
|
|
89
|
+
elif child.type == "file_scoped_namespace_declaration":
|
|
90
|
+
name_node = child.child_by_field_name("name")
|
|
91
|
+
file_scoped = _text(name_node, source) if name_node else "anon"
|
|
92
|
+
elif child.type in _CLASS_LIKE:
|
|
93
|
+
_collect_class(
|
|
94
|
+
child, file_scoped, source, file, shim, include_docstrings, out
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _collect_namespace(
|
|
99
|
+
node: Node,
|
|
100
|
+
parent_qn: str,
|
|
101
|
+
source: bytes,
|
|
102
|
+
file: str,
|
|
103
|
+
shim: bool,
|
|
104
|
+
include_docstrings: bool,
|
|
105
|
+
out: list[LockTarget],
|
|
106
|
+
) -> None:
|
|
107
|
+
name_node = node.child_by_field_name("name")
|
|
108
|
+
name = _text(name_node, source) if name_node else "anon"
|
|
109
|
+
qn = f"{parent_qn}.{name}" if parent_qn else name
|
|
110
|
+
body = node.child_by_field_name("body")
|
|
111
|
+
if body is None:
|
|
112
|
+
return
|
|
113
|
+
for child in body.named_children:
|
|
114
|
+
if child.type == "namespace_declaration":
|
|
115
|
+
_collect_namespace(child, qn, source, file, shim, include_docstrings, out)
|
|
116
|
+
elif child.type in _CLASS_LIKE:
|
|
117
|
+
_collect_class(child, qn, source, file, shim, include_docstrings, out)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _collect_class(
|
|
121
|
+
node: Node,
|
|
122
|
+
package_qn: str,
|
|
123
|
+
source: bytes,
|
|
124
|
+
file: str,
|
|
125
|
+
shim: bool,
|
|
126
|
+
include_docstrings: bool,
|
|
127
|
+
out: list[LockTarget],
|
|
128
|
+
) -> None:
|
|
129
|
+
name_node = node.child_by_field_name("name")
|
|
130
|
+
if name_node is None:
|
|
131
|
+
return
|
|
132
|
+
name = _text(name_node, source)
|
|
133
|
+
target = f"{package_qn}.{name}" if package_qn else name
|
|
134
|
+
|
|
135
|
+
_emit(node, target, "class", source, file, shim, include_docstrings, out)
|
|
136
|
+
|
|
137
|
+
body = node.child_by_field_name("body")
|
|
138
|
+
if body is None:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
# Group same-named members so overloads collapse into one target.
|
|
142
|
+
groups: dict[str, list[Node]] = {}
|
|
143
|
+
for member in body.named_children:
|
|
144
|
+
if member.type in _CLASS_LIKE:
|
|
145
|
+
_collect_class(member, target, source, file, shim, include_docstrings, out)
|
|
146
|
+
elif member.type in _MEMBER_LIKE:
|
|
147
|
+
member_name = _member_name(member, source)
|
|
148
|
+
if member_name:
|
|
149
|
+
groups.setdefault(member_name, []).append(member)
|
|
150
|
+
|
|
151
|
+
for member_name, nodes in groups.items():
|
|
152
|
+
_emit(
|
|
153
|
+
nodes,
|
|
154
|
+
f"{target}.{member_name}",
|
|
155
|
+
"method",
|
|
156
|
+
source,
|
|
157
|
+
file,
|
|
158
|
+
shim,
|
|
159
|
+
include_docstrings,
|
|
160
|
+
out,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _emit(
|
|
165
|
+
nodes: Node | list[Node],
|
|
166
|
+
target: str,
|
|
167
|
+
kind: str,
|
|
168
|
+
source: bytes,
|
|
169
|
+
file: str,
|
|
170
|
+
shim: bool,
|
|
171
|
+
include_docstrings: bool,
|
|
172
|
+
out: list[LockTarget],
|
|
173
|
+
) -> None:
|
|
174
|
+
group = nodes if isinstance(nodes, list) else [nodes]
|
|
175
|
+
digests = sorted(
|
|
176
|
+
sha256(_canonical(n, source, shim, include_docstrings).encode("utf-8")).hexdigest()
|
|
177
|
+
for n in group
|
|
178
|
+
)
|
|
179
|
+
digest = (
|
|
180
|
+
digests[0]
|
|
181
|
+
if len(digests) == 1
|
|
182
|
+
else sha256(("group:" + "|".join(digests)).encode("utf-8")).hexdigest()
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
declared = False
|
|
186
|
+
params: dict[str, str] = {}
|
|
187
|
+
for n in group:
|
|
188
|
+
for rule in extract_rules(n, source, shim):
|
|
189
|
+
if rule.name == LOCK_RULE:
|
|
190
|
+
declared = True
|
|
191
|
+
params = {**rule.kwargs, **params} if params else dict(rule.kwargs)
|
|
192
|
+
|
|
193
|
+
out.append(
|
|
194
|
+
LockTarget(
|
|
195
|
+
target=target,
|
|
196
|
+
kind=kind, # type: ignore[arg-type]
|
|
197
|
+
digest=digest,
|
|
198
|
+
algo=DIGEST_ALGO,
|
|
199
|
+
file=file,
|
|
200
|
+
line=group[0].start_point[0] + 1,
|
|
201
|
+
declared=declared,
|
|
202
|
+
params=params,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------- digest ----------
|
|
208
|
+
|
|
209
|
+
def _canonical(node: Node, source: bytes, shim: bool, include_docstrings: bool) -> str:
|
|
210
|
+
if node.type in _COMMENT_TYPES and not include_docstrings:
|
|
211
|
+
return ""
|
|
212
|
+
if node.type == "attribute" and _is_lock_attribute(node, source, shim):
|
|
213
|
+
return ""
|
|
214
|
+
if node.type == "attribute_list" and not _has_kept_attribute(node, source, shim):
|
|
215
|
+
# Dropping `[Locked]` must not leave an empty `[]` in the digest, or
|
|
216
|
+
# adding/removing a lock would change the enclosing member's hash.
|
|
217
|
+
return ""
|
|
218
|
+
if node.child_count == 0:
|
|
219
|
+
text = _text(node, source).strip()
|
|
220
|
+
return f"({node.type} {text!r})" if node.is_named else f"({text!r})"
|
|
221
|
+
parts = [
|
|
222
|
+
p
|
|
223
|
+
for p in (
|
|
224
|
+
_canonical(c, source, shim, include_docstrings) for c in node.children
|
|
225
|
+
)
|
|
226
|
+
if p
|
|
227
|
+
]
|
|
228
|
+
return f"({node.type} {' '.join(parts)})"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _is_lock_attribute(attr: Node, source: bytes, shim: bool) -> bool:
|
|
232
|
+
"""True when this single `attribute` node is the lock tag.
|
|
233
|
+
|
|
234
|
+
`extract_rules` works per *declaration* and returns catalog ids, which loses
|
|
235
|
+
the node-to-tag mapping we need here, so this mirrors its gating: an
|
|
236
|
+
attribute counts only when the shim namespace is imported or the attribute
|
|
237
|
+
is written fully qualified.
|
|
238
|
+
"""
|
|
239
|
+
name_node = attr.child_by_field_name("name")
|
|
240
|
+
raw = _text(name_node, source) if name_node else ""
|
|
241
|
+
if not raw:
|
|
242
|
+
return False
|
|
243
|
+
if not (shim or raw.startswith(CSHARP_SHIM_NAMESPACE + ".")):
|
|
244
|
+
return False
|
|
245
|
+
spec = by_csharp_name(raw.rsplit(".", 1)[-1])
|
|
246
|
+
return spec is not None and spec.id == LOCK_RULE
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _has_kept_attribute(attr_list: Node, source: bytes, shim: bool) -> bool:
|
|
250
|
+
for child in attr_list.named_children:
|
|
251
|
+
if child.type != "attribute":
|
|
252
|
+
continue
|
|
253
|
+
if not _is_lock_attribute(child, source, shim):
|
|
254
|
+
return True
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _member_name(node: Node, source: bytes) -> str:
|
|
259
|
+
name_node = node.child_by_field_name("name")
|
|
260
|
+
if name_node is not None:
|
|
261
|
+
return _text(name_node, source)
|
|
262
|
+
if node.type == "constructor_declaration":
|
|
263
|
+
return ".ctor"
|
|
264
|
+
if node.type == "destructor_declaration":
|
|
265
|
+
return ".dtor"
|
|
266
|
+
if node.type == "indexer_declaration":
|
|
267
|
+
return "this[]"
|
|
268
|
+
return ""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _text(node: Node | None, source: bytes) -> str:
|
|
272
|
+
if node is None:
|
|
273
|
+
return ""
|
|
274
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|