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.
Files changed (116) hide show
  1. code_constraints/__init__.py +1 -0
  2. code_constraints/cli/__init__.py +0 -0
  3. code_constraints/cli/__main__.py +1555 -0
  4. code_constraints/cli/_assets/agents/cdec-architect.md +468 -0
  5. code_constraints/cli/_assets/agents/oop-refactor-architect.md +317 -0
  6. code_constraints/cli/_assets/shims/csharp/CodeConstraintsRules.cs +94 -0
  7. code_constraints/cli/_assets/shims/julia/CdecRules.jl +129 -0
  8. code_constraints/cli/_assets/shims/lua/cdec_rules.lua +92 -0
  9. code_constraints/cli/_assets/shims/odin/cdec_rules.odin +67 -0
  10. code_constraints/cli/_assets/shims/python/cdec_rules.py +94 -0
  11. code_constraints/cli/_assets/skills/cdec-architecture-loop/SKILL.md +152 -0
  12. code_constraints/cli/depstamp.py +118 -0
  13. code_constraints/cli/detect.py +77 -0
  14. code_constraints/cli/interactive.py +304 -0
  15. code_constraints/cli/scaffold.py +602 -0
  16. code_constraints/cli/update.py +157 -0
  17. code_constraints/core/__init__.py +41 -0
  18. code_constraints/core/annotations.py +217 -0
  19. code_constraints/core/associations.py +134 -0
  20. code_constraints/core/diff.py +302 -0
  21. code_constraints/core/editor_io.py +280 -0
  22. code_constraints/core/graph_model.py +681 -0
  23. code_constraints/core/keys.py +105 -0
  24. code_constraints/core/model.py +294 -0
  25. code_constraints/core/model_io.py +65 -0
  26. code_constraints/core/receivers.py +34 -0
  27. code_constraints/core/rules.py +177 -0
  28. code_constraints/core/rulesdoc.py +208 -0
  29. code_constraints/core/tags.py +114 -0
  30. code_constraints/core/ts_fingerprint.py +88 -0
  31. code_constraints/core/xmi_reader.py +358 -0
  32. code_constraints/core/xmi_writer.py +373 -0
  33. code_constraints/csharp/__init__.py +3 -0
  34. code_constraints/csharp/activity.py +250 -0
  35. code_constraints/csharp/conformance.py +331 -0
  36. code_constraints/csharp/fingerprint.py +274 -0
  37. code_constraints/csharp/parser.py +436 -0
  38. code_constraints/csharp/rules_extract.py +78 -0
  39. code_constraints/csharp/sequence.py +295 -0
  40. code_constraints/enforce/__init__.py +15 -0
  41. code_constraints/enforce/engine.py +122 -0
  42. code_constraints/enforce/model.py +74 -0
  43. code_constraints/julia/__init__.py +5 -0
  44. code_constraints/julia/conformance.py +282 -0
  45. code_constraints/julia/fingerprint.py +226 -0
  46. code_constraints/julia/parser.py +523 -0
  47. code_constraints/julia/rules_extract.py +216 -0
  48. code_constraints/lint/__init__.py +10 -0
  49. code_constraints/lint/baseline.py +96 -0
  50. code_constraints/lint/config.py +239 -0
  51. code_constraints/lint/engine.py +179 -0
  52. code_constraints/lint/pipeline.py +108 -0
  53. code_constraints/lint/report.py +151 -0
  54. code_constraints/lint/rules/__init__.py +50 -0
  55. code_constraints/lint/rules/base.py +200 -0
  56. code_constraints/lint/rules/cyclic_package_dependencies.py +69 -0
  57. code_constraints/lint/rules/dangling_classes.py +98 -0
  58. code_constraints/lint/rules/forbidden_package_references.py +47 -0
  59. code_constraints/lint/rules/forbidden_references.py +48 -0
  60. code_constraints/lint/rules/frozen_members.py +67 -0
  61. code_constraints/lint/rules/frozen_rules.py +105 -0
  62. code_constraints/lint/rules/implementation_locks.py +156 -0
  63. code_constraints/lint/rules/layer_dependencies.py +92 -0
  64. code_constraints/lint/rules/max_class_fanout.py +41 -0
  65. code_constraints/lint/rules/no_new_classes.py +27 -0
  66. code_constraints/lint/rules/no_removed_classes.py +27 -0
  67. code_constraints/lint/rules/reference_architecture.py +111 -0
  68. code_constraints/lint/rules/subclass_naming.py +71 -0
  69. code_constraints/lint/rules/tag_conformance.py +76 -0
  70. code_constraints/lock/__init__.py +73 -0
  71. code_constraints/lock/engine.py +395 -0
  72. code_constraints/lock/model.py +235 -0
  73. code_constraints/lock/store.py +144 -0
  74. code_constraints/lua/__init__.py +5 -0
  75. code_constraints/lua/conformance.py +239 -0
  76. code_constraints/lua/fingerprint.py +252 -0
  77. code_constraints/lua/parser.py +500 -0
  78. code_constraints/lua/rules_extract.py +55 -0
  79. code_constraints/mcp/__init__.py +20 -0
  80. code_constraints/mcp/__main__.py +73 -0
  81. code_constraints/mcp/server.py +1203 -0
  82. code_constraints/odin/__init__.py +5 -0
  83. code_constraints/odin/conformance.py +244 -0
  84. code_constraints/odin/fingerprint.py +159 -0
  85. code_constraints/odin/parser.py +471 -0
  86. code_constraints/odin/rules_extract.py +38 -0
  87. code_constraints/python/__init__.py +3 -0
  88. code_constraints/python/activity.py +278 -0
  89. code_constraints/python/conformance.py +249 -0
  90. code_constraints/python/fingerprint.py +231 -0
  91. code_constraints/python/parser.py +330 -0
  92. code_constraints/python/rules_extract.py +83 -0
  93. code_constraints/python/sequence.py +257 -0
  94. code_constraints/reference/__init__.py +15 -0
  95. code_constraints/reference/compare.py +356 -0
  96. code_constraints/reference/report.py +38 -0
  97. code_constraints/svelte/__init__.py +3 -0
  98. code_constraints/svelte/parser.py +523 -0
  99. code_constraints/typescript/__init__.py +3 -0
  100. code_constraints/typescript/parser.py +590 -0
  101. code_constraints/waivers/__init__.py +89 -0
  102. code_constraints/waivers/collect.py +167 -0
  103. code_constraints/waivers/model.py +90 -0
  104. code_constraints/waivers/ops.py +150 -0
  105. code_constraints/waivers/review.py +156 -0
  106. code_constraints/waivers/store.py +300 -0
  107. code_constraints/web/__init__.py +0 -0
  108. code_constraints/web/_static/assets/index-3ivBsYY4.css +1 -0
  109. code_constraints/web/_static/assets/index-BTzTqGFp.js +9 -0
  110. code_constraints/web/_static/index.html +13 -0
  111. code_constraints/web/app.py +1076 -0
  112. code_constraints-0.1.0.dist-info/METADATA +663 -0
  113. code_constraints-0.1.0.dist-info/RECORD +116 -0
  114. code_constraints-0.1.0.dist-info/WHEEL +4 -0
  115. code_constraints-0.1.0.dist-info/entry_points.txt +3 -0
  116. 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")