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,373 @@
1
+ """Serialise `Project` to XMI 2.1 (OMG standard) with a custom diff namespace.
2
+
3
+ XMI itself has no concept of "this element was added / removed in a diff";
4
+ we attach `cdec:status` and `cdec:changeKind` attributes in a private namespace
5
+ that conforming readers are free to ignore.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from hashlib import sha1
11
+ from pathlib import Path
12
+
13
+ from lxml import etree
14
+
15
+ from code_constraints.core.model import (
16
+ Activity,
17
+ ActivityEdge,
18
+ ActivityNode,
19
+ Association,
20
+ Attribute,
21
+ Class,
22
+ DiffStatus,
23
+ EdgeLayout,
24
+ Layout,
25
+ Message,
26
+ Operation,
27
+ Package,
28
+ Parameter,
29
+ Project,
30
+ RuleAnnotation,
31
+ Sequence,
32
+ Visibility,
33
+ )
34
+
35
+ NS = {
36
+ "xmi": "http://www.omg.org/spec/XMI/20110701",
37
+ "uml": "http://www.omg.org/spec/UML/20110701",
38
+ "cdec": "http://code-constraints/ext/1",
39
+ }
40
+ XMI = f"{{{NS['xmi']}}}"
41
+ UML = f"{{{NS['uml']}}}"
42
+ CDEC = f"{{{NS['cdec']}}}"
43
+
44
+ XMI_VERSION = "2.1"
45
+
46
+
47
+ def write_project(project: Project, path: str | Path) -> Path:
48
+ """Serialise `project` to `path` (XMI 2.1). Returns the path."""
49
+ tree = build_tree(project)
50
+ out = Path(path)
51
+ out.parent.mkdir(parents=True, exist_ok=True)
52
+ tree.write(
53
+ str(out),
54
+ pretty_print=True,
55
+ xml_declaration=True,
56
+ encoding="UTF-8",
57
+ )
58
+ return out
59
+
60
+
61
+ def build_tree(project: Project) -> etree._ElementTree:
62
+ root = etree.Element(f"{XMI}XMI", nsmap=NS)
63
+ root.set(f"{XMI}version", XMI_VERSION)
64
+
65
+ model = etree.SubElement(root, f"{UML}Model")
66
+ model.set(f"{XMI}id", "model-root")
67
+ model.set("name", "RootModel")
68
+ _set_status_attr(model, DiffStatus.UNCHANGED) # always emit so readers see it
69
+ model.set(f"{CDEC}sourceLanguage", project.source_language)
70
+ if project.root_path:
71
+ model.set(f"{CDEC}rootPath", project.root_path)
72
+
73
+ for pkg in project.packages:
74
+ _write_package(model, pkg)
75
+
76
+ for activity in project.activities:
77
+ _write_activity(model, activity)
78
+
79
+ for seq in project.sequences:
80
+ _write_sequence(model, seq)
81
+
82
+ for assoc in project.associations:
83
+ _write_association(model, assoc)
84
+
85
+ return etree.ElementTree(root)
86
+
87
+
88
+ def _write_association(parent: etree._Element, assoc: Association) -> None:
89
+ el = etree.SubElement(parent, "packagedElement")
90
+ el.set(f"{XMI}type", "uml:Association")
91
+ if assoc.name:
92
+ el.set("name", assoc.name)
93
+ el.set(f"{CDEC}source", assoc.source)
94
+ el.set(f"{CDEC}target", assoc.target)
95
+ if assoc.source_multiplicity:
96
+ el.set(f"{CDEC}sourceMultiplicity", assoc.source_multiplicity)
97
+ if assoc.target_multiplicity:
98
+ el.set(f"{CDEC}targetMultiplicity", assoc.target_multiplicity)
99
+ if assoc.source_role:
100
+ el.set(f"{CDEC}sourceRole", assoc.source_role)
101
+ if assoc.target_role:
102
+ el.set(f"{CDEC}targetRole", assoc.target_role)
103
+ _set_status_attr(el, assoc.status)
104
+
105
+
106
+ # ---------- packages / classes ----------
107
+
108
+ def _write_package(parent: etree._Element, pkg: Package) -> None:
109
+ el = etree.SubElement(parent, "packagedElement")
110
+ el.set(f"{XMI}type", "uml:Package")
111
+ el.set(f"{XMI}id", pkg.stable_id())
112
+ el.set("name", pkg.name)
113
+ el.set(f"{CDEC}qualifiedName", pkg.qualified_name)
114
+ _set_status_attr(el, pkg.status)
115
+ _set_layout_attrs(el, pkg.layout)
116
+ _write_owned_comment(el, pkg.description, owner_key=f"Package|{pkg.qualified_name}")
117
+ for cls in pkg.classes:
118
+ _write_class(el, cls)
119
+ for sub in pkg.sub_packages:
120
+ _write_package(el, sub)
121
+
122
+
123
+ def _write_class(parent: etree._Element, cls: Class) -> None:
124
+ el = etree.SubElement(parent, "packagedElement")
125
+ el.set(f"{XMI}type", _class_xmi_type(cls.kind))
126
+ el.set(f"{XMI}id", cls.stable_id())
127
+ el.set("name", cls.name)
128
+ el.set(f"{CDEC}qualifiedName", cls.qualified_name)
129
+ el.set(f"{CDEC}kind", cls.kind)
130
+ _set_status_attr(el, cls.status)
131
+ _set_layout_attrs(el, cls.layout)
132
+ if cls.kind == "abstract":
133
+ el.set("isAbstract", "true")
134
+ if cls.location is not None:
135
+ el.set(f"{CDEC}file", cls.location.file)
136
+ el.set(f"{CDEC}startLine", str(cls.location.start_line))
137
+ el.set(f"{CDEC}endLine", str(cls.location.end_line))
138
+
139
+ _write_owned_comment(el, cls.description, owner_key=f"Class|{cls.qualified_name}")
140
+ _write_rules(el, cls.rules)
141
+
142
+ for base in cls.bases:
143
+ gen = etree.SubElement(el, "generalization")
144
+ gen.set(f"{XMI}type", "uml:Generalization")
145
+ gen.set(f"{CDEC}general", base)
146
+
147
+ for dep in cls.dependencies:
148
+ dep_el = etree.SubElement(el, f"{CDEC}dependency")
149
+ dep_el.set("type", dep)
150
+
151
+ for attr in cls.attributes:
152
+ _write_attribute(el, attr, owner_qname=cls.qualified_name)
153
+ for op in cls.operations:
154
+ _write_operation(el, op, owner_qname=cls.qualified_name)
155
+
156
+
157
+ def _class_xmi_type(kind: str) -> str:
158
+ if kind == "interface":
159
+ return "uml:Interface"
160
+ if kind == "enum":
161
+ return "uml:Enumeration"
162
+ return "uml:Class"
163
+
164
+
165
+ def _write_attribute(parent: etree._Element, attr: Attribute, *, owner_qname: str = "") -> None:
166
+ el = etree.SubElement(parent, "ownedAttribute")
167
+ el.set(f"{XMI}type", "uml:Property")
168
+ el.set("name", attr.name)
169
+ el.set("visibility", attr.visibility.value)
170
+ el.set(f"{CDEC}type", attr.type)
171
+ if attr.is_static:
172
+ el.set("isStatic", "true")
173
+ if attr.is_readonly:
174
+ el.set("isReadOnly", "true")
175
+ if attr.default is not None:
176
+ el.set(f"{CDEC}default", attr.default)
177
+ _set_status_attr(el, attr.status)
178
+ _write_owned_comment(
179
+ el, attr.description, owner_key=f"Attribute|{owner_qname}|{attr.signature()}"
180
+ )
181
+
182
+
183
+ def _write_operation(parent: etree._Element, op: Operation, *, owner_qname: str = "") -> None:
184
+ el = etree.SubElement(parent, "ownedOperation")
185
+ el.set(f"{XMI}type", "uml:Operation")
186
+ el.set("name", op.name)
187
+ el.set("visibility", op.visibility.value)
188
+ if op.is_static:
189
+ el.set("isStatic", "true")
190
+ if op.is_abstract:
191
+ el.set("isAbstract", "true")
192
+ _set_status_attr(el, op.status)
193
+ _write_owned_comment(
194
+ el, op.description, owner_key=f"Operation|{owner_qname}|{op.signature()}"
195
+ )
196
+ _write_rules(el, op.rules)
197
+ for param in op.parameters:
198
+ _write_parameter(el, param, direction="in")
199
+ if op.return_type:
200
+ _write_parameter(el, Parameter(name="return", type=op.return_type), direction="return")
201
+
202
+
203
+ def _write_parameter(parent: etree._Element, p: Parameter, *, direction: str) -> None:
204
+ el = etree.SubElement(parent, "ownedParameter")
205
+ el.set(f"{XMI}type", "uml:Parameter")
206
+ el.set("name", p.name)
207
+ el.set("direction", direction)
208
+ el.set(f"{CDEC}type", p.type)
209
+ if p.default is not None:
210
+ el.set(f"{CDEC}default", p.default)
211
+
212
+
213
+ # ---------- activities ----------
214
+
215
+ def _write_activity(parent: etree._Element, act: Activity) -> None:
216
+ el = etree.SubElement(parent, "packagedElement")
217
+ el.set(f"{XMI}type", "uml:Activity")
218
+ el.set(f"{XMI}id", act.stable_id())
219
+ el.set("name", act.name)
220
+ el.set(f"{CDEC}granularity", act.granularity)
221
+ _set_status_attr(el, act.status)
222
+ if act.location is not None:
223
+ el.set(f"{CDEC}file", act.location.file)
224
+ el.set(f"{CDEC}startLine", str(act.location.start_line))
225
+ el.set(f"{CDEC}endLine", str(act.location.end_line))
226
+
227
+ for node in act.nodes:
228
+ _write_activity_node(el, node)
229
+ for edge in act.edges:
230
+ _write_activity_edge(el, edge)
231
+
232
+
233
+ _NODE_XMI_TYPE = {
234
+ "initial": "uml:InitialNode",
235
+ "final": "uml:ActivityFinalNode",
236
+ "action": "uml:OpaqueAction",
237
+ "decision": "uml:DecisionNode",
238
+ "merge": "uml:MergeNode",
239
+ "fork": "uml:ForkNode",
240
+ "join": "uml:JoinNode",
241
+ }
242
+
243
+
244
+ def _write_activity_node(parent: etree._Element, node: ActivityNode) -> None:
245
+ el = etree.SubElement(parent, "node")
246
+ el.set(f"{XMI}type", _NODE_XMI_TYPE[node.kind])
247
+ el.set(f"{XMI}id", node.id)
248
+ if node.label:
249
+ el.set("name", node.label)
250
+ _set_status_attr(el, node.status)
251
+ _set_layout_attrs(el, node.layout)
252
+
253
+
254
+ def _write_activity_edge(parent: etree._Element, edge: ActivityEdge) -> None:
255
+ el = etree.SubElement(parent, "edge")
256
+ el.set(f"{XMI}type", "uml:ControlFlow")
257
+ el.set("source", edge.source)
258
+ el.set("target", edge.target)
259
+ if edge.guard:
260
+ el.set(f"{CDEC}guard", edge.guard)
261
+ _set_status_attr(el, edge.status)
262
+ _set_edge_layout_attrs(el, edge.edge_layout)
263
+
264
+
265
+ # ---------- sequences ----------
266
+
267
+ def _write_sequence(parent: etree._Element, seq: Sequence) -> None:
268
+ el = etree.SubElement(parent, "packagedElement")
269
+ el.set(f"{XMI}type", "uml:Interaction")
270
+ el.set(f"{XMI}id", seq.stable_id())
271
+ el.set("name", seq.name)
272
+ _set_status_attr(el, seq.status)
273
+ if seq.location is not None:
274
+ el.set(f"{CDEC}file", seq.location.file)
275
+ el.set(f"{CDEC}startLine", str(seq.location.start_line))
276
+ el.set(f"{CDEC}endLine", str(seq.location.end_line))
277
+
278
+ for lifeline in seq.lifelines:
279
+ ll = etree.SubElement(el, "lifeline")
280
+ ll.set(f"{XMI}type", "uml:Lifeline")
281
+ ll.set("name", lifeline.name)
282
+ ll.set(f"{CDEC}represents", lifeline.represents)
283
+ if lifeline.column_x is not None:
284
+ ll.set(f"{CDEC}columnX", str(lifeline.column_x))
285
+ _set_status_attr(ll, lifeline.status)
286
+
287
+ for msg in seq.messages:
288
+ m = etree.SubElement(el, "message")
289
+ m.set(f"{XMI}type", "uml:Message")
290
+ m.set("name", msg.label)
291
+ m.set(f"{CDEC}sender", msg.sender)
292
+ m.set(f"{CDEC}receiver", msg.receiver)
293
+ if msg.is_return:
294
+ m.set(f"{CDEC}isReturn", "true")
295
+ if msg.guard:
296
+ m.set(f"{CDEC}guard", msg.guard)
297
+ _set_status_attr(m, msg.status)
298
+
299
+ for frag in seq.fragments:
300
+ f = etree.SubElement(el, "fragment")
301
+ f.set(f"{XMI}type", "uml:CombinedFragment")
302
+ f.set(f"{CDEC}kind", frag.kind)
303
+ f.set("name", frag.label)
304
+ f.set(f"{CDEC}startRow", str(frag.start_row))
305
+ f.set(f"{CDEC}endRow", str(frag.end_row))
306
+ _set_status_attr(f, frag.status)
307
+
308
+
309
+ # ---------- helpers ----------
310
+
311
+ def _write_owned_comment(parent: etree._Element, text: str | None, *, owner_key: str) -> None:
312
+ """Emit a standards-compliant `<ownedComment xmi:type="uml:Comment">` child.
313
+
314
+ `owner_key` is folded into a deterministic `xmi:id` so repeated emits of the
315
+ same project produce byte-identical XMI.
316
+ """
317
+ if not text:
318
+ return
319
+ el = etree.SubElement(parent, "ownedComment")
320
+ el.set(f"{XMI}type", "uml:Comment")
321
+ el.set(f"{XMI}id", _comment_id(owner_key))
322
+ body = etree.SubElement(el, "body")
323
+ body.text = text
324
+
325
+
326
+ def _comment_id(owner_key: str) -> str:
327
+ h = sha1(f"comment|{owner_key}".encode("utf-8")).hexdigest()[:16]
328
+ return f"comment-{h}"
329
+
330
+
331
+ def _write_rules(parent: etree._Element, rules: list[RuleAnnotation]) -> None:
332
+ """Emit one `<cdec:rule>` child per architectural-rule tag, preserving
333
+ declaration order. Args/kwargs round-trip as `<cdec:arg>` / `<cdec:kwarg>`
334
+ children so the source text survives verbatim."""
335
+ for rule in rules:
336
+ el = etree.SubElement(parent, f"{CDEC}rule")
337
+ el.set("name", rule.name)
338
+ for value in rule.args:
339
+ arg = etree.SubElement(el, f"{CDEC}arg")
340
+ arg.set("value", value)
341
+ for key, value in rule.kwargs.items():
342
+ kw = etree.SubElement(el, f"{CDEC}kwarg")
343
+ kw.set("key", key)
344
+ kw.set("value", value)
345
+
346
+
347
+ def _set_status_attr(el: etree._Element, status: DiffStatus) -> None:
348
+ el.set(f"{CDEC}status", status.value)
349
+
350
+
351
+ def _set_layout_attrs(el: etree._Element, layout: Layout | None) -> None:
352
+ if layout is None:
353
+ return
354
+ el.set(f"{CDEC}x", _fmt_num(layout.x))
355
+ el.set(f"{CDEC}y", _fmt_num(layout.y))
356
+ el.set(f"{CDEC}width", _fmt_num(layout.width))
357
+ el.set(f"{CDEC}height", _fmt_num(layout.height))
358
+ if layout.collapsed:
359
+ el.set(f"{CDEC}collapsed", "true")
360
+
361
+
362
+ def _set_edge_layout_attrs(el: etree._Element, edge_layout: EdgeLayout | None) -> None:
363
+ if edge_layout is None or not edge_layout.waypoints:
364
+ return
365
+ encoded = ";".join(f"{_fmt_num(x)},{_fmt_num(y)}" for x, y in edge_layout.waypoints)
366
+ el.set(f"{CDEC}waypoints", encoded)
367
+
368
+
369
+ def _fmt_num(n: float) -> str:
370
+ """Compact numeric format: integers stay integral, others go through repr."""
371
+ if n == int(n):
372
+ return str(int(n))
373
+ return repr(n)
@@ -0,0 +1,3 @@
1
+ from code_constraints.csharp.parser import parse_project
2
+
3
+ __all__ = ["parse_project"]
@@ -0,0 +1,250 @@
1
+ """Build a UML `Activity` from a tagged C# region.
2
+
3
+ Mirrors `code_constraints.python.activity` but operates on tree-sitter nodes. Only the
4
+ control-flow granularity is fully featured; statement/calls fall back to
5
+ flat action sequences.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from tree_sitter import Node, Tree
11
+
12
+ from code_constraints.core.model import Activity, ActivityEdge, ActivityNode, SourceLocation
13
+ from code_constraints.core.tags import TagInstance
14
+
15
+
16
+ def build_activity_from_tag(
17
+ tag: TagInstance, tree: Tree, source: bytes, *, file: str
18
+ ) -> Activity | None:
19
+ if not tag.name:
20
+ return None
21
+ granularity = tag.attributes.get("granularity", "control-flow")
22
+ if granularity not in {"control-flow", "statement", "calls"}:
23
+ granularity = "control-flow"
24
+
25
+ stmts = _statements_in_range(tree.root_node, tag.start_line, tag.end_line)
26
+ if not stmts:
27
+ return None
28
+
29
+ builder = _ActivityBuilder()
30
+ builder.start()
31
+ if granularity == "statement":
32
+ for s in stmts:
33
+ builder.action(_short(_text(s, source)))
34
+ elif granularity == "calls":
35
+ for s in stmts:
36
+ for call in _walk_calls(s):
37
+ builder.action(_short(_text(call, source)))
38
+ else:
39
+ for s in stmts:
40
+ _emit_control_flow(builder, s, source)
41
+ builder.finish()
42
+
43
+ return Activity(
44
+ name=tag.name,
45
+ nodes=builder.nodes,
46
+ edges=builder.edges,
47
+ granularity=granularity, # type: ignore[arg-type]
48
+ location=SourceLocation(
49
+ file=file, start_line=tag.start_line, end_line=tag.end_line
50
+ ),
51
+ )
52
+
53
+
54
+ _BODY_HOLDERS = {
55
+ "method_declaration",
56
+ "constructor_declaration",
57
+ "local_function_statement",
58
+ "destructor_declaration",
59
+ }
60
+
61
+
62
+ def _statements_in_range(root: Node, start: int, end: int) -> list[Node]:
63
+ """Find the body that contains the tag region, then return its top-level
64
+ statements whose first line falls strictly inside [start, end]."""
65
+ best: list[Node] | None = None
66
+ best_start = -1
67
+
68
+ stack: list[Node] = [root]
69
+ while stack:
70
+ node = stack.pop()
71
+ if node.type in _BODY_HOLDERS:
72
+ body = node.child_by_field_name("body")
73
+ if body is not None and body.type == "block":
74
+ first_stmt = next(
75
+ (c for c in body.named_children if not _is_comment(c)), None
76
+ )
77
+ if first_stmt is None:
78
+ continue
79
+ body_start_line = first_stmt.start_point[0] + 1
80
+ body_end_line = body.end_point[0] + 1
81
+ node_def_line = node.start_point[0] + 1
82
+ if node_def_line < start and body_end_line >= start:
83
+ stmts = [
84
+ c
85
+ for c in body.named_children
86
+ if not _is_comment(c)
87
+ and start < (c.start_point[0] + 1) < end
88
+ ]
89
+ if stmts and node_def_line > best_start:
90
+ best = stmts
91
+ best_start = node_def_line
92
+ for child in node.children:
93
+ stack.append(child)
94
+ return best or []
95
+
96
+
97
+ def _is_comment(node: Node) -> bool:
98
+ return node.type == "comment"
99
+
100
+
101
+ class _ActivityBuilder:
102
+ def __init__(self) -> None:
103
+ self.nodes: list[ActivityNode] = []
104
+ self.edges: list[ActivityEdge] = []
105
+ self._counter = 0
106
+ self._last_id: str | None = None
107
+
108
+ def _new_id(self, prefix: str) -> str:
109
+ self._counter += 1
110
+ return f"{prefix}{self._counter}"
111
+
112
+ def _add(self, node: ActivityNode) -> str:
113
+ self.nodes.append(node)
114
+ if self._last_id is not None:
115
+ self.edges.append(ActivityEdge(source=self._last_id, target=node.id))
116
+ self._last_id = node.id
117
+ return node.id
118
+
119
+ def start(self) -> None:
120
+ self._add(ActivityNode(id=self._new_id("init"), kind="initial"))
121
+
122
+ def finish(self) -> None:
123
+ self._add(ActivityNode(id=self._new_id("final"), kind="final"))
124
+
125
+ def action(self, label: str) -> str:
126
+ return self._add(ActivityNode(id=self._new_id("a"), kind="action", label=label))
127
+
128
+ def decision(self, label: str) -> str:
129
+ node = ActivityNode(id=self._new_id("d"), kind="decision", label=label)
130
+ if self._last_id is not None:
131
+ self.edges.append(ActivityEdge(source=self._last_id, target=node.id))
132
+ self.nodes.append(node)
133
+ self._last_id = node.id
134
+ return node.id
135
+
136
+
137
+ def _emit_control_flow(builder: _ActivityBuilder, stmt: Node, source: bytes) -> None:
138
+ if stmt.type == "if_statement":
139
+ _emit_if(builder, stmt, source)
140
+ elif stmt.type in ("for_statement", "for_each_statement", "while_statement"):
141
+ _emit_loop(builder, stmt, source)
142
+ elif stmt.type == "return_statement":
143
+ builder.action(_short("return " + _text(stmt, source)))
144
+ elif stmt.type == "throw_statement":
145
+ builder.action(_short("throw " + _text(stmt, source)))
146
+ elif stmt.type == "try_statement":
147
+ builder.action("try")
148
+ # catch clauses are surfaced as follow-on actions
149
+ for c in stmt.named_children:
150
+ if c.type == "catch_clause":
151
+ builder.action("catch")
152
+ else:
153
+ builder.action(_short(_text(stmt, source)))
154
+
155
+
156
+ def _emit_if(builder: _ActivityBuilder, stmt: Node, source: bytes) -> None:
157
+ cond = stmt.child_by_field_name("condition")
158
+ cond_text = _short(_text(cond, source)) if cond is not None else "?"
159
+ decision_id = builder.decision(cond_text)
160
+ merge_id = builder._new_id("m")
161
+ merge = ActivityNode(id=merge_id, kind="merge")
162
+
163
+ consequence = stmt.child_by_field_name("consequence")
164
+ alternative = stmt.child_by_field_name("alternative")
165
+
166
+ builder._last_id = decision_id
167
+ prev = len(builder.edges)
168
+ if consequence is not None:
169
+ _emit_block_like(builder, consequence, source)
170
+ if len(builder.edges) > prev:
171
+ builder.edges[prev].guard = "yes"
172
+ if builder._last_id is not None:
173
+ builder.edges.append(ActivityEdge(source=builder._last_id, target=merge_id))
174
+
175
+ builder._last_id = decision_id
176
+ prev = len(builder.edges)
177
+ if alternative is not None:
178
+ _emit_block_like(builder, alternative, source)
179
+ if len(builder.edges) > prev:
180
+ builder.edges[prev].guard = "no"
181
+ if builder._last_id is not None:
182
+ builder.edges.append(ActivityEdge(source=builder._last_id, target=merge_id))
183
+ else:
184
+ builder.edges.append(
185
+ ActivityEdge(source=decision_id, target=merge_id, guard="no")
186
+ )
187
+
188
+ builder.nodes.append(merge)
189
+ builder._last_id = merge_id
190
+
191
+
192
+ def _emit_loop(builder: _ActivityBuilder, stmt: Node, source: bytes) -> None:
193
+ cond = stmt.child_by_field_name("condition")
194
+ label = "loop"
195
+ if cond is not None:
196
+ label = _short(_text(cond, source))
197
+ else:
198
+ label = _short(_text(stmt, source).split("{")[0])
199
+ decision_id = builder.decision(label)
200
+ merge_id = builder._new_id("m")
201
+ merge = ActivityNode(id=merge_id, kind="merge")
202
+
203
+ body = stmt.child_by_field_name("body")
204
+ builder._last_id = decision_id
205
+ prev = len(builder.edges)
206
+ if body is not None:
207
+ _emit_block_like(builder, body, source)
208
+ if len(builder.edges) > prev:
209
+ builder.edges[prev].guard = "loop"
210
+ if builder._last_id is not None:
211
+ builder.edges.append(ActivityEdge(source=builder._last_id, target=decision_id))
212
+
213
+ builder.edges.append(
214
+ ActivityEdge(source=decision_id, target=merge_id, guard="exit")
215
+ )
216
+ builder.nodes.append(merge)
217
+ builder._last_id = merge_id
218
+
219
+
220
+ def _emit_block_like(builder: _ActivityBuilder, node: Node, source: bytes) -> None:
221
+ if node.type == "block":
222
+ for child in node.named_children:
223
+ if _is_comment(child):
224
+ continue
225
+ _emit_control_flow(builder, child, source)
226
+ else:
227
+ _emit_control_flow(builder, node, source)
228
+
229
+
230
+ def _walk_calls(node: Node) -> list[Node]:
231
+ out: list[Node] = []
232
+ stack = [node]
233
+ while stack:
234
+ n = stack.pop()
235
+ if n.type == "invocation_expression":
236
+ out.append(n)
237
+ for c in n.children:
238
+ stack.append(c)
239
+ return out
240
+
241
+
242
+ def _text(node: Node | None, source: bytes) -> str:
243
+ if node is None:
244
+ return ""
245
+ return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
246
+
247
+
248
+ def _short(text: str, limit: int = 60) -> str:
249
+ first = next((line for line in text.splitlines() if line.strip()), text).strip()
250
+ return first[:limit] + ("…" if len(first) > limit else "")