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,436 @@
|
|
|
1
|
+
"""Parse a C# project (directory of .cs files) into a `code_constraints.core.Project`.
|
|
2
|
+
|
|
3
|
+
Uses `tree_sitter` with the `tree_sitter_c_sharp` grammar. This is a syntactic
|
|
4
|
+
parse only — there is no semantic / type resolution, so inheritance lines may
|
|
5
|
+
appear as their textual form rather than as fully qualified names. That is a
|
|
6
|
+
documented limitation accepted for v1.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import tree_sitter_c_sharp
|
|
15
|
+
from tree_sitter import Language, Node, Parser
|
|
16
|
+
|
|
17
|
+
from code_constraints.core.model import (
|
|
18
|
+
Attribute,
|
|
19
|
+
Class,
|
|
20
|
+
ClassKind,
|
|
21
|
+
Operation,
|
|
22
|
+
Package,
|
|
23
|
+
Parameter,
|
|
24
|
+
Project,
|
|
25
|
+
SourceLocation,
|
|
26
|
+
Visibility,
|
|
27
|
+
)
|
|
28
|
+
from code_constraints.core.tags import find_tags
|
|
29
|
+
from code_constraints.csharp.activity import build_activity_from_tag
|
|
30
|
+
from code_constraints.csharp.rules_extract import extract_rules, using_has_shim
|
|
31
|
+
from code_constraints.csharp.sequence import build_sequence_from_tag
|
|
32
|
+
|
|
33
|
+
_LANG = Language(tree_sitter_c_sharp.language())
|
|
34
|
+
_PARSER = Parser(_LANG)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_project(root: str | Path) -> Project:
|
|
38
|
+
root_path = Path(root).resolve()
|
|
39
|
+
if not root_path.is_dir():
|
|
40
|
+
raise ValueError(f"not a directory: {root_path}")
|
|
41
|
+
|
|
42
|
+
project = Project(source_language="csharp", root_path=str(root_path))
|
|
43
|
+
package_index: dict[str, Package] = {}
|
|
44
|
+
|
|
45
|
+
for cs_file in sorted(root_path.rglob("*.cs")):
|
|
46
|
+
if _should_skip(cs_file):
|
|
47
|
+
continue
|
|
48
|
+
_parse_file(cs_file, root_path, project, package_index)
|
|
49
|
+
|
|
50
|
+
return project
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_SKIP_DIR_NAMES = {"bin", "obj", ".git", "packages", "TestResults"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _should_skip(p: Path) -> bool:
|
|
57
|
+
return any(part in _SKIP_DIR_NAMES for part in p.parts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_file(
|
|
61
|
+
file: Path,
|
|
62
|
+
root: Path,
|
|
63
|
+
project: Project,
|
|
64
|
+
package_index: dict[str, Package],
|
|
65
|
+
) -> None:
|
|
66
|
+
source_bytes = file.read_bytes()
|
|
67
|
+
tree = _PARSER.parse(source_bytes)
|
|
68
|
+
file_str = str(file.relative_to(root).as_posix())
|
|
69
|
+
source_text = source_bytes.decode("utf-8", errors="replace")
|
|
70
|
+
|
|
71
|
+
# Find every namespace_declaration (including file-scoped) and class-like
|
|
72
|
+
# declarations directly inside the file (which may belong to an implicit
|
|
73
|
+
# global namespace).
|
|
74
|
+
cu = tree.root_node
|
|
75
|
+
shim_in_scope = using_has_shim(cu, source_bytes)
|
|
76
|
+
# File-scoped namespace claims every subsequent top-level declaration.
|
|
77
|
+
file_scoped_ns: str | None = None
|
|
78
|
+
for child in cu.named_children:
|
|
79
|
+
if child.type == "namespace_declaration":
|
|
80
|
+
_visit_namespace(
|
|
81
|
+
child, "", project, package_index, source_bytes, file_str, shim_in_scope
|
|
82
|
+
)
|
|
83
|
+
elif child.type == "file_scoped_namespace_declaration":
|
|
84
|
+
name_node = child.child_by_field_name("name")
|
|
85
|
+
file_scoped_ns = (
|
|
86
|
+
_text(name_node, source_bytes) if name_node else "anon"
|
|
87
|
+
)
|
|
88
|
+
_ensure_package(project, file_scoped_ns, package_index)
|
|
89
|
+
elif child.type in _CLASS_LIKE_TYPES:
|
|
90
|
+
target_ns = file_scoped_ns or "__root__"
|
|
91
|
+
_ingest_class(
|
|
92
|
+
child, target_ns, project, package_index, source_bytes, file_str, shim_in_scope
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Tag-driven activities & sequences
|
|
96
|
+
tags = find_tags(source_text, comment_prefix="//")
|
|
97
|
+
for tag in tags:
|
|
98
|
+
if tag.kind == "uml-activity" and not tag.self_closed:
|
|
99
|
+
act = build_activity_from_tag(tag, tree, source_bytes, file=file_str)
|
|
100
|
+
if act is not None:
|
|
101
|
+
project.activities.append(act)
|
|
102
|
+
elif tag.kind == "uml-sequence" and not tag.self_closed:
|
|
103
|
+
seq = build_sequence_from_tag(tag, tree, source_bytes, file=file_str)
|
|
104
|
+
if seq is not None:
|
|
105
|
+
project.sequences.append(seq)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_CLASS_LIKE_TYPES = {
|
|
109
|
+
"class_declaration",
|
|
110
|
+
"interface_declaration",
|
|
111
|
+
"struct_declaration",
|
|
112
|
+
"record_declaration",
|
|
113
|
+
"record_struct_declaration",
|
|
114
|
+
"enum_declaration",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _visit_namespace(
|
|
119
|
+
node: Node,
|
|
120
|
+
parent_qn: str,
|
|
121
|
+
project: Project,
|
|
122
|
+
package_index: dict[str, Package],
|
|
123
|
+
source: bytes,
|
|
124
|
+
file_str: str,
|
|
125
|
+
shim_in_scope: bool,
|
|
126
|
+
) -> None:
|
|
127
|
+
name_node = node.child_by_field_name("name")
|
|
128
|
+
name = _text(name_node, source) if name_node else "anon"
|
|
129
|
+
qn = name if not parent_qn else f"{parent_qn}.{name}"
|
|
130
|
+
pkg = _ensure_package(project, qn, package_index)
|
|
131
|
+
|
|
132
|
+
body = node.child_by_field_name("body")
|
|
133
|
+
if body is None:
|
|
134
|
+
return
|
|
135
|
+
for child in body.named_children:
|
|
136
|
+
if child.type == "namespace_declaration":
|
|
137
|
+
_visit_namespace(child, qn, project, package_index, source, file_str, shim_in_scope)
|
|
138
|
+
elif child.type in _CLASS_LIKE_TYPES:
|
|
139
|
+
_ingest_class(child, qn, project, package_index, source, file_str, shim_in_scope)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _ensure_package(
|
|
143
|
+
project: Project, qualified_name: str, index: dict[str, Package]
|
|
144
|
+
) -> Package:
|
|
145
|
+
if qualified_name in index:
|
|
146
|
+
return index[qualified_name]
|
|
147
|
+
if qualified_name == "__root__":
|
|
148
|
+
pkg = Package(name="__root__", qualified_name="__root__")
|
|
149
|
+
project.packages.append(pkg)
|
|
150
|
+
index[qualified_name] = pkg
|
|
151
|
+
return pkg
|
|
152
|
+
parts = qualified_name.split(".")
|
|
153
|
+
if len(parts) == 1:
|
|
154
|
+
pkg = Package(name=parts[0], qualified_name=qualified_name)
|
|
155
|
+
project.packages.append(pkg)
|
|
156
|
+
else:
|
|
157
|
+
parent = _ensure_package(project, ".".join(parts[:-1]), index)
|
|
158
|
+
pkg = Package(name=parts[-1], qualified_name=qualified_name)
|
|
159
|
+
parent.sub_packages.append(pkg)
|
|
160
|
+
index[qualified_name] = pkg
|
|
161
|
+
return pkg
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _ingest_class(
|
|
165
|
+
node: Node,
|
|
166
|
+
package_qn: str,
|
|
167
|
+
project: Project,
|
|
168
|
+
package_index: dict[str, Package],
|
|
169
|
+
source: bytes,
|
|
170
|
+
file_str: str,
|
|
171
|
+
shim_in_scope: bool,
|
|
172
|
+
) -> None:
|
|
173
|
+
name_node = node.child_by_field_name("name")
|
|
174
|
+
if name_node is None:
|
|
175
|
+
return
|
|
176
|
+
name = _text(name_node, source)
|
|
177
|
+
qn = f"{package_qn}.{name}" if package_qn and package_qn != "__root__" else name
|
|
178
|
+
|
|
179
|
+
cls = Class(
|
|
180
|
+
name=name,
|
|
181
|
+
qualified_name=qn,
|
|
182
|
+
kind=_kind_for(node, source),
|
|
183
|
+
bases=_bases(node, source),
|
|
184
|
+
location=SourceLocation(
|
|
185
|
+
file=file_str,
|
|
186
|
+
start_line=node.start_point[0] + 1,
|
|
187
|
+
end_line=node.end_point[0] + 1,
|
|
188
|
+
),
|
|
189
|
+
description=_xml_doc_for(node, source),
|
|
190
|
+
rules=extract_rules(node, source, shim_in_scope),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
body = node.child_by_field_name("body")
|
|
194
|
+
if body is not None:
|
|
195
|
+
for member in body.named_children:
|
|
196
|
+
_ingest_member(member, cls, source, shim_in_scope)
|
|
197
|
+
|
|
198
|
+
cls.dependencies = _dedupe_refs(cls.dependencies, drop=cls.name)
|
|
199
|
+
|
|
200
|
+
pkg = _ensure_package(project, package_qn, package_index)
|
|
201
|
+
pkg.classes.append(cls)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _kind_for(node: Node, source: bytes) -> ClassKind:
|
|
205
|
+
if node.type == "interface_declaration":
|
|
206
|
+
return "interface"
|
|
207
|
+
if node.type == "struct_declaration":
|
|
208
|
+
return "struct"
|
|
209
|
+
if node.type in ("record_declaration", "record_struct_declaration"):
|
|
210
|
+
return "record"
|
|
211
|
+
if node.type == "enum_declaration":
|
|
212
|
+
return "enum"
|
|
213
|
+
modifiers = _modifiers(node, source)
|
|
214
|
+
if "static" in modifiers:
|
|
215
|
+
return "static"
|
|
216
|
+
if "abstract" in modifiers:
|
|
217
|
+
return "abstract"
|
|
218
|
+
return "class"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _modifiers(node: Node, source: bytes) -> list[str]:
|
|
222
|
+
mods: list[str] = []
|
|
223
|
+
for c in node.children:
|
|
224
|
+
if c.type == "modifier":
|
|
225
|
+
mods.append(_text(c, source))
|
|
226
|
+
return mods
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _bases(node: Node, source: bytes) -> list[str]:
|
|
230
|
+
base_list = next(
|
|
231
|
+
(c for c in node.children if c.type == "base_list"), None
|
|
232
|
+
)
|
|
233
|
+
if base_list is None:
|
|
234
|
+
return []
|
|
235
|
+
bases: list[str] = []
|
|
236
|
+
for child in base_list.named_children:
|
|
237
|
+
bases.append(_text(child, source))
|
|
238
|
+
return bases
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
_PASCAL_IDENT_RE = re.compile(r"^[A-Z][A-Za-z0-9_]*$")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _body_type_refs(node: Node, source: bytes) -> list[str]:
|
|
245
|
+
"""Collect PascalCase identifier tokens used inside a method/ctor body.
|
|
246
|
+
|
|
247
|
+
Heuristic (no type resolution): a PascalCase identifier in the body is a
|
|
248
|
+
candidate type reference — static-call receivers (`ControlUtils.Foo()`),
|
|
249
|
+
object creations (`new Foo()`), local declarations, generic args, etc.
|
|
250
|
+
Downstream `resolve_association` keeps only the ones that map to a project
|
|
251
|
+
class; Unity types, locals and method names simply drop out. Only the `body`
|
|
252
|
+
field is walked (the block, or the arrow-expression clause for expression-
|
|
253
|
+
bodied members), so `[Attribute(...)]` annotations and parameter names in
|
|
254
|
+
the signature never leak in.
|
|
255
|
+
"""
|
|
256
|
+
body = node.child_by_field_name("body")
|
|
257
|
+
if body is None:
|
|
258
|
+
return []
|
|
259
|
+
out: list[str] = []
|
|
260
|
+
seen: set[str] = set()
|
|
261
|
+
stack = [body]
|
|
262
|
+
while stack:
|
|
263
|
+
n = stack.pop()
|
|
264
|
+
stack.extend(n.children)
|
|
265
|
+
if n.type == "identifier":
|
|
266
|
+
txt = _text(n, source)
|
|
267
|
+
if txt and txt not in seen and _PASCAL_IDENT_RE.match(txt):
|
|
268
|
+
seen.add(txt)
|
|
269
|
+
out.append(txt)
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _dedupe_refs(refs: list[str], *, drop: str = "") -> list[str]:
|
|
274
|
+
"""Order-preserving dedupe, dropping a given name (the owning class)."""
|
|
275
|
+
out: list[str] = []
|
|
276
|
+
seen: set[str] = set()
|
|
277
|
+
for r in refs:
|
|
278
|
+
if r == drop or r in seen:
|
|
279
|
+
continue
|
|
280
|
+
seen.add(r)
|
|
281
|
+
out.append(r)
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _ingest_member(node: Node, cls: Class, source: bytes, shim_in_scope: bool) -> None:
|
|
286
|
+
if node.type == "field_declaration":
|
|
287
|
+
cls.attributes.extend(_attributes_from_field(node, source))
|
|
288
|
+
elif node.type == "property_declaration":
|
|
289
|
+
cls.attributes.append(_attribute_from_property(node, source))
|
|
290
|
+
elif node.type in ("method_declaration", "constructor_declaration"):
|
|
291
|
+
cls.operations.append(_operation_from_method(node, source, shim_in_scope))
|
|
292
|
+
cls.dependencies.extend(_body_type_refs(node, source))
|
|
293
|
+
elif node.type == "enum_member_declaration":
|
|
294
|
+
name_node = node.child_by_field_name("name")
|
|
295
|
+
if name_node is not None:
|
|
296
|
+
cls.attributes.append(
|
|
297
|
+
Attribute(
|
|
298
|
+
name=_text(name_node, source),
|
|
299
|
+
type=cls.name,
|
|
300
|
+
visibility=Visibility.PUBLIC,
|
|
301
|
+
is_static=True,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _attributes_from_field(node: Node, source: bytes) -> list[Attribute]:
|
|
307
|
+
# tree-sitter-c-sharp puts the type on the inner `variable_declaration`,
|
|
308
|
+
# NOT on the outer `field_declaration`. Calling child_by_field_name("type")
|
|
309
|
+
# on the field_declaration returns None, which silently drops the type.
|
|
310
|
+
mods = _modifiers(node, source)
|
|
311
|
+
vis = _visibility_from_modifiers(mods)
|
|
312
|
+
is_static = "static" in mods
|
|
313
|
+
is_readonly = "readonly" in mods or "const" in mods
|
|
314
|
+
|
|
315
|
+
decl = next((c for c in node.named_children if c.type == "variable_declaration"), None)
|
|
316
|
+
if decl is None:
|
|
317
|
+
return []
|
|
318
|
+
type_node = decl.child_by_field_name("type")
|
|
319
|
+
type_text = _text(type_node, source) if type_node else ""
|
|
320
|
+
|
|
321
|
+
out: list[Attribute] = []
|
|
322
|
+
for declarator in decl.named_children:
|
|
323
|
+
if declarator.type != "variable_declarator":
|
|
324
|
+
continue
|
|
325
|
+
name_node = declarator.child_by_field_name("name")
|
|
326
|
+
if name_node is None:
|
|
327
|
+
continue
|
|
328
|
+
out.append(
|
|
329
|
+
Attribute(
|
|
330
|
+
name=_text(name_node, source),
|
|
331
|
+
type=type_text,
|
|
332
|
+
visibility=vis,
|
|
333
|
+
is_static=is_static,
|
|
334
|
+
is_readonly=is_readonly,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
return out
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _attribute_from_property(node: Node, source: bytes) -> Attribute:
|
|
341
|
+
name_node = node.child_by_field_name("name")
|
|
342
|
+
type_node = node.child_by_field_name("type")
|
|
343
|
+
mods = _modifiers(node, source)
|
|
344
|
+
return Attribute(
|
|
345
|
+
name=_text(name_node, source) if name_node else "",
|
|
346
|
+
type=_text(type_node, source) if type_node else "",
|
|
347
|
+
visibility=_visibility_from_modifiers(mods),
|
|
348
|
+
is_static="static" in mods,
|
|
349
|
+
is_readonly="readonly" in mods,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _operation_from_method(node: Node, source: bytes, shim_in_scope: bool = False) -> Operation:
|
|
354
|
+
mods = _modifiers(node, source)
|
|
355
|
+
name_node = node.child_by_field_name("name")
|
|
356
|
+
name = _text(name_node, source) if name_node else "<ctor>"
|
|
357
|
+
return_node = node.child_by_field_name("returns") or node.child_by_field_name("type")
|
|
358
|
+
return_type = _text(return_node, source) if return_node else ""
|
|
359
|
+
|
|
360
|
+
params: list[Parameter] = []
|
|
361
|
+
param_list = node.child_by_field_name("parameters")
|
|
362
|
+
if param_list is not None:
|
|
363
|
+
for p in param_list.named_children:
|
|
364
|
+
if p.type != "parameter":
|
|
365
|
+
continue
|
|
366
|
+
p_name = p.child_by_field_name("name")
|
|
367
|
+
p_type = p.child_by_field_name("type")
|
|
368
|
+
params.append(
|
|
369
|
+
Parameter(
|
|
370
|
+
name=_text(p_name, source) if p_name else "",
|
|
371
|
+
type=_text(p_type, source) if p_type else "",
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
return Operation(
|
|
376
|
+
name=name,
|
|
377
|
+
parameters=params,
|
|
378
|
+
return_type=return_type,
|
|
379
|
+
visibility=_visibility_from_modifiers(mods),
|
|
380
|
+
is_static="static" in mods,
|
|
381
|
+
is_abstract="abstract" in mods,
|
|
382
|
+
description=_xml_doc_for(node, source),
|
|
383
|
+
rules=extract_rules(node, source, shim_in_scope),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _visibility_from_modifiers(mods: list[str]) -> Visibility:
|
|
388
|
+
if "private" in mods:
|
|
389
|
+
return Visibility.PRIVATE
|
|
390
|
+
if "protected" in mods:
|
|
391
|
+
return Visibility.PROTECTED
|
|
392
|
+
if "internal" in mods and "protected" not in mods:
|
|
393
|
+
return Visibility.PACKAGE
|
|
394
|
+
if "public" in mods:
|
|
395
|
+
return Visibility.PUBLIC
|
|
396
|
+
return Visibility.PRIVATE # C# default for class members
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _text(node: Node | None, source: bytes) -> str:
|
|
400
|
+
if node is None:
|
|
401
|
+
return ""
|
|
402
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _xml_doc_for(node: Node, source: bytes) -> str | None:
|
|
406
|
+
"""Collect `///` XML-doc lines immediately preceding `node`.
|
|
407
|
+
|
|
408
|
+
Returns the text inside `<summary>` if present, otherwise the tag-stripped
|
|
409
|
+
concatenation. Returns None when there's no `///` block above the node.
|
|
410
|
+
"""
|
|
411
|
+
lines: list[str] = []
|
|
412
|
+
sib = node.prev_sibling
|
|
413
|
+
while sib is not None and sib.type == "comment":
|
|
414
|
+
raw = _text(sib, source).strip()
|
|
415
|
+
if not raw.startswith("///"):
|
|
416
|
+
break
|
|
417
|
+
content = raw[3:]
|
|
418
|
+
if content.startswith(" "):
|
|
419
|
+
content = content[1:]
|
|
420
|
+
lines.insert(0, content)
|
|
421
|
+
sib = sib.prev_sibling
|
|
422
|
+
if not lines:
|
|
423
|
+
return None
|
|
424
|
+
joined = "\n".join(lines).strip()
|
|
425
|
+
if not joined:
|
|
426
|
+
return None
|
|
427
|
+
m = re.search(r"<summary\b[^>]*>(.*?)</summary>", joined, re.DOTALL)
|
|
428
|
+
if m:
|
|
429
|
+
# Strip inner XML-doc tags (<see/>, <paramref/>, <c>...</c>, etc.) so
|
|
430
|
+
# only the prose survives. The crefs would otherwise show up as raw
|
|
431
|
+
# markup in the viewer.
|
|
432
|
+
stripped = re.sub(r"<[^>]+>", "", m.group(1))
|
|
433
|
+
summary = " ".join(stripped.split())
|
|
434
|
+
return summary or None
|
|
435
|
+
fallback = " ".join(re.sub(r"<[^>]+>", "", joined).split())
|
|
436
|
+
return fallback or None
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Recognise architectural-rule attributes on C# declarations.
|
|
2
|
+
|
|
3
|
+
Shared by the UML parser and the `cdec enforce` conformance analyzer. An
|
|
4
|
+
attribute counts as a rule only when the file has `using CodeConstraints.Rules;`
|
|
5
|
+
(or the attribute is written fully qualified) — so a same-named user attribute
|
|
6
|
+
is never misread as a rule.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from tree_sitter import Node
|
|
12
|
+
|
|
13
|
+
from code_constraints.core.model import RuleAnnotation
|
|
14
|
+
from code_constraints.core.rules import CSHARP_SHIM_NAMESPACE, by_csharp_name
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def using_has_shim(root: Node, source: bytes) -> bool:
|
|
18
|
+
"""True when the compilation unit imports the shim namespace."""
|
|
19
|
+
stack = [root]
|
|
20
|
+
while stack:
|
|
21
|
+
node = stack.pop()
|
|
22
|
+
if node.type == "using_directive" and CSHARP_SHIM_NAMESPACE in _text(node, source):
|
|
23
|
+
return True
|
|
24
|
+
stack.extend(node.children)
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def extract_rules(decl_node: Node, source: bytes, shim_in_scope: bool) -> list[RuleAnnotation]:
|
|
29
|
+
rules: list[RuleAnnotation] = []
|
|
30
|
+
for child in decl_node.children:
|
|
31
|
+
if child.type != "attribute_list":
|
|
32
|
+
continue
|
|
33
|
+
for attr in child.named_children:
|
|
34
|
+
if attr.type != "attribute":
|
|
35
|
+
continue
|
|
36
|
+
name_node = attr.child_by_field_name("name")
|
|
37
|
+
raw_name = _text(name_node, source) if name_node else ""
|
|
38
|
+
if not raw_name:
|
|
39
|
+
continue
|
|
40
|
+
qualified_shim = raw_name.startswith(CSHARP_SHIM_NAMESPACE + ".")
|
|
41
|
+
if not (shim_in_scope or qualified_shim):
|
|
42
|
+
continue
|
|
43
|
+
spec = by_csharp_name(raw_name.rsplit(".", 1)[-1])
|
|
44
|
+
if spec is None:
|
|
45
|
+
continue
|
|
46
|
+
args, kwargs = _attr_args(attr, source)
|
|
47
|
+
rules.append(RuleAnnotation(name=spec.id, args=args, kwargs=kwargs))
|
|
48
|
+
return rules
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _attr_args(attr: Node, source: bytes) -> tuple[list[str], dict[str, str]]:
|
|
52
|
+
args: list[str] = []
|
|
53
|
+
kwargs: dict[str, str] = {}
|
|
54
|
+
arglist = next(
|
|
55
|
+
(c for c in attr.children if c.type == "attribute_argument_list"), None
|
|
56
|
+
)
|
|
57
|
+
if arglist is None:
|
|
58
|
+
return args, kwargs
|
|
59
|
+
for a in arglist.children:
|
|
60
|
+
if a.type != "attribute_argument":
|
|
61
|
+
continue
|
|
62
|
+
kids = a.children
|
|
63
|
+
# Named arg: `identifier = value` (or `identifier : value`).
|
|
64
|
+
sep = next((i for i, c in enumerate(kids) if c.type in ("=", ":")), None)
|
|
65
|
+
if sep is not None and sep >= 1 and kids[sep - 1].type == "identifier":
|
|
66
|
+
name = _text(kids[sep - 1], source)
|
|
67
|
+
value = next((c for c in kids[sep + 1 :] if c.is_named), None)
|
|
68
|
+
kwargs[name] = _text(value, source) if value else ""
|
|
69
|
+
else:
|
|
70
|
+
value = next((c for c in kids if c.is_named), None)
|
|
71
|
+
args.append(_text(value, source) if value else "")
|
|
72
|
+
return args, kwargs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _text(node: Node | None, source: bytes) -> str:
|
|
76
|
+
if node is None:
|
|
77
|
+
return ""
|
|
78
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|