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,681 @@
1
+ """Build SvelteFlow-friendly JSON graph payloads from a `Project`.
2
+
3
+ The DOT emitter renders to a static SVG; this module renders to a dict that
4
+ the frontend hydrates into an interactive SvelteFlow canvas. The two share the
5
+ inheritance + association edge logic via `code_constraints.core.associations`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from code_constraints.core.associations import resolve_association
13
+ from code_constraints.core.model import (
14
+ Activity,
15
+ Attribute,
16
+ Class,
17
+ DiffStatus,
18
+ Operation,
19
+ Package,
20
+ Project,
21
+ RuleAnnotation,
22
+ Sequence,
23
+ )
24
+
25
+
26
+ # C# enum backing types (and the like) land in the base list (`enum X : byte`)
27
+ # but are not supertypes — don't render them as external base nodes.
28
+ _PRIMITIVE_BASE_NAMES = frozenset({
29
+ "byte", "sbyte", "short", "ushort", "int", "uint", "long", "ulong",
30
+ "char", "bool", "float", "double", "decimal", "string", "object",
31
+ })
32
+
33
+ _SEQUENCE_SAFE = str.maketrans({".": "_", "/": "_", " ": "_", "<": "_", ">": "_", "(": "_", ")": "_"})
34
+
35
+
36
+ def _seq_safe(name: str) -> str:
37
+ return name.translate(_SEQUENCE_SAFE)
38
+
39
+
40
+ def build_class_graph(project: Project) -> dict[str, Any]:
41
+ """Return {nodes, edges, meta} for a UML class diagram.
42
+
43
+ Node and edge ids match `Class.stable_id()` so they survive re-parses and
44
+ can drive cross-revision matching for the diff walkthrough.
45
+ """
46
+ # Dedupe by stable_id: two classes sharing a qualified name (C# partials,
47
+ # accidentally duplicated module paths, etc.) would otherwise produce
48
+ # duplicate SvelteFlow node ids and crash the canvas with each_key_duplicate.
49
+ # Keep the first occurrence; later duplicates are silently dropped.
50
+ seen_ids: set[str] = set()
51
+ classes: list[Class] = []
52
+ for c in project.iter_classes():
53
+ if c.stable_id() in seen_ids:
54
+ continue
55
+ seen_ids.add(c.stable_id())
56
+ classes.append(c)
57
+ qname_to_id: dict[str, str] = {c.qualified_name: c.stable_id() for c in classes}
58
+ short_counts: dict[str, int] = {}
59
+ for c in classes:
60
+ short_counts[c.name] = short_counts.get(c.name, 0) + 1
61
+ short_to_id: dict[str, str] = {
62
+ c.name: c.stable_id() for c in classes if short_counts[c.name] == 1
63
+ }
64
+
65
+ nodes: list[dict[str, Any]] = [_class_to_node(cls) for cls in classes]
66
+
67
+ edges: list[dict[str, Any]] = []
68
+ inheritance_pairs: set[tuple[str, str]] = set()
69
+
70
+ # External supertypes (framework bases like MonoBehaviour, or interfaces not
71
+ # defined in the project) get a single shared placeholder node each, so a
72
+ # class that only inherits from outside the project still reads as connected
73
+ # rather than orphaned.
74
+ external_node_ids: set[str] = set()
75
+
76
+ def _external_base_node(base: str) -> str:
77
+ simple = base.split(".")[-1].split("<")[0].strip() or base
78
+ nid = f"external::{simple}"
79
+ if nid not in external_node_ids:
80
+ external_node_ids.add(nid)
81
+ nodes.append({
82
+ "id": nid,
83
+ "qualifiedName": simple,
84
+ "name": simple,
85
+ "kind": "external",
86
+ "external": True,
87
+ "package": "",
88
+ "status": "unchanged",
89
+ "description": None,
90
+ "attributes": [],
91
+ "operations": [],
92
+ "rules": [],
93
+ "location": None,
94
+ })
95
+ return nid
96
+
97
+ # Inheritance: child → parent. Bases that resolve to a project class link to
98
+ # that node; bases that don't (framework types, external interfaces) link to
99
+ # a shared external placeholder node.
100
+ for cls in classes:
101
+ source_id = cls.stable_id()
102
+ for base in cls.bases:
103
+ target_id = qname_to_id.get(base) or short_to_id.get(base.split(".")[-1])
104
+ if target_id is None:
105
+ simple = base.split(".")[-1].split("<")[0].strip()
106
+ if simple in _PRIMITIVE_BASE_NAMES:
107
+ continue
108
+ target_id = _external_base_node(base)
109
+ if target_id == source_id:
110
+ continue
111
+ pair = (source_id, target_id)
112
+ if pair in inheritance_pairs:
113
+ continue
114
+ inheritance_pairs.add(pair)
115
+ edges.append({
116
+ "id": f"{source_id}--{target_id}--inheritance",
117
+ "source": source_id,
118
+ "target": target_id,
119
+ "kind": "inheritance",
120
+ "multiplicity": "",
121
+ "status": cls.status.value,
122
+ # The base reference lives on the source (child) class header —
123
+ # recorded so the edge-focus popup can highlight where it comes from.
124
+ "members": [{"kind": "inheritance", "signature": base}],
125
+ })
126
+
127
+ # Associations collapse to one edge per (source, target) pair, but several
128
+ # members of the source class can contribute to the same edge (a field, a
129
+ # method return/param, an explicit association). We keep one edge dict per
130
+ # pair and accumulate every contributing member onto it, so the edge-focus
131
+ # popup can highlight exactly which rows of the source class produce the
132
+ # reference. Multiplicity/status keep the FIRST contributor's value.
133
+ assoc_edge_by_pair: dict[tuple[str, str], dict[str, Any]] = {}
134
+
135
+ def _record_assoc(
136
+ source_id: str,
137
+ target_id: str,
138
+ multiplicity: str,
139
+ status_val: str,
140
+ member: dict[str, str] | None,
141
+ ) -> None:
142
+ if source_id == target_id:
143
+ return
144
+ pair = (source_id, target_id)
145
+ # An inheritance edge already covers this pair — don't shadow it with an
146
+ # association (matches the original dedup behaviour).
147
+ if pair in inheritance_pairs:
148
+ return
149
+ edge = assoc_edge_by_pair.get(pair)
150
+ if edge is None:
151
+ edge = {
152
+ "id": f"{source_id}--{target_id}--association",
153
+ "source": source_id,
154
+ "target": target_id,
155
+ "kind": "association",
156
+ "multiplicity": multiplicity,
157
+ "status": status_val,
158
+ "members": [],
159
+ }
160
+ assoc_edge_by_pair[pair] = edge
161
+ edges.append(edge)
162
+ if member is not None and member not in edge["members"]:
163
+ edge["members"].append(member)
164
+
165
+ # Field-derived associations (aggregation/composition).
166
+ for cls in classes:
167
+ source_id = cls.stable_id()
168
+ for attr in cls.attributes:
169
+ target_qn, multiplicity = resolve_association(attr.type, project)
170
+ if target_qn is None:
171
+ continue
172
+ target_id = qname_to_id[target_qn]
173
+ _record_assoc(
174
+ source_id,
175
+ target_id,
176
+ multiplicity,
177
+ attr.status.value,
178
+ {"kind": "attribute", "signature": attr.signature()},
179
+ )
180
+
181
+ # Associations implied by *usage*: method parameter / return types and types
182
+ # referenced inside method bodies (`cls.dependencies`). A class used only as
183
+ # an argument, a return value, or via a static call still connects to the
184
+ # types it depends on. Return/param uses pin to the owning operation row;
185
+ # body-level dependencies can't be pinned to a member, so they add no member.
186
+ def _add_usage_assoc(
187
+ source_id: str,
188
+ raw_type: str,
189
+ status_val: str,
190
+ member: dict[str, str] | None,
191
+ ) -> None:
192
+ target_qn, multiplicity = resolve_association(raw_type, project)
193
+ if target_qn is None:
194
+ return
195
+ target_id = qname_to_id.get(target_qn)
196
+ if target_id is None:
197
+ return
198
+ _record_assoc(source_id, target_id, multiplicity, status_val, member)
199
+
200
+ for cls in classes:
201
+ source_id = cls.stable_id()
202
+ for op in cls.operations:
203
+ op_member = {"kind": "operation", "signature": op.signature()}
204
+ _add_usage_assoc(source_id, op.return_type, op.status.value, op_member)
205
+ for param in op.parameters:
206
+ _add_usage_assoc(source_id, param.type, op.status.value, op_member)
207
+ for dep in cls.dependencies:
208
+ _add_usage_assoc(source_id, dep, cls.status.value, None)
209
+
210
+ # Explicit associations declared on the project (editor-authored). Dedup
211
+ # against attribute-derived ones so a class that both has a typed field and
212
+ # an explicit association doesn't double-render. If only the explicit form
213
+ # exists, that's the one we emit. Editor-authored, so no source member row
214
+ # to pin to.
215
+ for assoc in project.associations:
216
+ source_id = qname_to_id.get(assoc.source) or short_to_id.get(
217
+ assoc.source.split(".")[-1]
218
+ )
219
+ target_id = qname_to_id.get(assoc.target) or short_to_id.get(
220
+ assoc.target.split(".")[-1]
221
+ )
222
+ if source_id is None or target_id is None:
223
+ continue
224
+ multiplicity = assoc.target_multiplicity or assoc.source_multiplicity or ""
225
+ _record_assoc(source_id, target_id, multiplicity, assoc.status.value, None)
226
+
227
+ return {
228
+ "nodes": nodes,
229
+ "edges": edges,
230
+ "meta": {
231
+ "count": len(nodes),
232
+ "sourceLanguage": project.source_language,
233
+ },
234
+ }
235
+
236
+
237
+ def _class_to_node(cls: Class) -> dict[str, Any]:
238
+ package = cls.qualified_name.rsplit(".", 1)[0] if "." in cls.qualified_name else ""
239
+ return {
240
+ "id": cls.stable_id(),
241
+ "qualifiedName": cls.qualified_name,
242
+ "name": cls.name,
243
+ "kind": cls.kind,
244
+ "package": package,
245
+ "status": cls.status.value,
246
+ "description": cls.description,
247
+ "attributes": [_attr(a) for a in cls.attributes],
248
+ "operations": [_op(o) for o in cls.operations],
249
+ "rules": [_rule(r) for r in cls.rules],
250
+ "location": _location(cls),
251
+ }
252
+
253
+
254
+ def _rule(r: RuleAnnotation) -> dict[str, Any]:
255
+ return {"name": r.name, "args": list(r.args), "kwargs": dict(r.kwargs)}
256
+
257
+
258
+ def _attr(a: Attribute) -> dict[str, Any]:
259
+ return {
260
+ "name": a.name,
261
+ "type": a.type,
262
+ "visibility": a.visibility.value,
263
+ "isStatic": a.is_static,
264
+ "status": a.status.value,
265
+ "signature": a.signature(),
266
+ "description": a.description,
267
+ }
268
+
269
+
270
+ def _op(o: Operation) -> dict[str, Any]:
271
+ return {
272
+ "name": o.name,
273
+ "signature": o.signature(),
274
+ "returnType": o.return_type,
275
+ "visibility": o.visibility.value,
276
+ "isStatic": o.is_static,
277
+ "isAbstract": o.is_abstract,
278
+ "status": o.status.value,
279
+ "description": o.description,
280
+ "rules": [_rule(r) for r in o.rules],
281
+ }
282
+
283
+
284
+ def _location(cls: Class) -> dict[str, Any] | None:
285
+ if cls.location is None:
286
+ return None
287
+ return {
288
+ "file": cls.location.file,
289
+ "startLine": cls.location.start_line,
290
+ "endLine": cls.location.end_line,
291
+ }
292
+
293
+
294
+ def build_package_graph(project: Project) -> dict[str, Any]:
295
+ """Nodes and dependency edges for a UML package diagram.
296
+
297
+ Emits one flat node per package. Dependency edges are derived by
298
+ aggregating class-level inheritance + association edges to the package
299
+ level: if any class in package A references any class in package B
300
+ (A ≠ B), there's a `dependency` edge from A to B. Edges are deduped per
301
+ (source, target) pair.
302
+
303
+ Defensive: dedupes by `stable_id` so duplicate qualified names (e.g. C#
304
+ partial classes) don't produce duplicate SvelteFlow node ids.
305
+ """
306
+ # Walk every package (recursively) for the flat node list.
307
+ seen_ids: set[str] = set()
308
+ pkg_nodes: list[dict[str, Any]] = []
309
+ pkg_by_qname: dict[str, Package] = {}
310
+ for pkg in _walk_pkgs(project.packages):
311
+ if pkg.stable_id() in seen_ids:
312
+ continue
313
+ seen_ids.add(pkg.stable_id())
314
+ pkg_by_qname[pkg.qualified_name] = pkg
315
+ # Recursively count classes in self + sub-packages so the badge is meaningful.
316
+ total_classes = sum(len(p.classes) for p in _walk_pkgs([pkg]))
317
+ pkg_nodes.append({
318
+ "id": pkg.stable_id(),
319
+ "kind": "package",
320
+ "name": pkg.name,
321
+ "qualifiedName": pkg.qualified_name,
322
+ "parentQname": (
323
+ pkg.qualified_name.rsplit(".", 1)[0]
324
+ if "." in pkg.qualified_name
325
+ else ""
326
+ ),
327
+ "status": pkg.status.value,
328
+ "description": pkg.description,
329
+ "classCount": total_classes,
330
+ })
331
+
332
+ # Map class qualified_name → owning-package qualified_name.
333
+ cls_to_pkg_qname: dict[str, str] = {}
334
+ for pkg in _walk_pkgs(project.packages):
335
+ for cls in pkg.classes:
336
+ cls_to_pkg_qname[cls.qualified_name] = pkg.qualified_name
337
+
338
+ pkg_id_by_qname: dict[str, str] = {
339
+ n["qualifiedName"]: n["id"] for n in pkg_nodes
340
+ }
341
+
342
+ # Walk class graph once to derive package-level dependencies.
343
+ class_graph = build_class_graph(project)
344
+ # We need to map class-graph node ids → class qualified_name (already on the node).
345
+ cls_id_to_qname: dict[str, str] = {
346
+ n["id"]: n["qualifiedName"] for n in class_graph["nodes"]
347
+ }
348
+
349
+ seen_pairs: set[tuple[str, str]] = set()
350
+ edges: list[dict[str, Any]] = []
351
+ for e in class_graph["edges"]:
352
+ src_cls = cls_id_to_qname.get(e["source"])
353
+ tgt_cls = cls_id_to_qname.get(e["target"])
354
+ if not src_cls or not tgt_cls:
355
+ continue
356
+ src_pkg_qname = cls_to_pkg_qname.get(src_cls)
357
+ tgt_pkg_qname = cls_to_pkg_qname.get(tgt_cls)
358
+ if not src_pkg_qname or not tgt_pkg_qname:
359
+ continue
360
+ if src_pkg_qname == tgt_pkg_qname:
361
+ continue
362
+ src_id = pkg_id_by_qname.get(src_pkg_qname)
363
+ tgt_id = pkg_id_by_qname.get(tgt_pkg_qname)
364
+ if not src_id or not tgt_id:
365
+ continue
366
+ key = (src_id, tgt_id)
367
+ if key in seen_pairs:
368
+ continue
369
+ seen_pairs.add(key)
370
+ # Propagate diff status pessimistically: if any underlying class-level
371
+ # edge is non-unchanged, mark the dependency as changed.
372
+ edges.append({
373
+ "id": f"{src_id}--{tgt_id}",
374
+ "source": src_id,
375
+ "target": tgt_id,
376
+ "kind": "dependency",
377
+ "multiplicity": "",
378
+ "status": e.get("status", "unchanged"),
379
+ })
380
+
381
+ return {
382
+ "nodes": pkg_nodes,
383
+ "edges": edges,
384
+ "meta": {"count": len(pkg_nodes), "edgeCount": len(edges)},
385
+ }
386
+
387
+
388
+ def _walk_pkgs(packages: list[Package]):
389
+ for p in packages:
390
+ yield p
391
+ yield from _walk_pkgs(p.sub_packages)
392
+
393
+
394
+ def build_activity_graph(project: Project, name: str) -> dict[str, Any]:
395
+ """Nodes + edges for one activity diagram, keyed by activity name."""
396
+ act = next((a for a in project.activities if a.name == name), None)
397
+ if act is None:
398
+ raise KeyError(f"activity {name!r} not found")
399
+
400
+ nodes = [
401
+ {
402
+ "id": n.id,
403
+ "kind": n.kind,
404
+ "label": n.label,
405
+ "status": n.status.value,
406
+ }
407
+ for n in act.nodes
408
+ ]
409
+ edges = [
410
+ {
411
+ "id": f"{e.source}--{e.target}--{i}",
412
+ "source": e.source,
413
+ "target": e.target,
414
+ "guard": e.guard,
415
+ "status": e.status.value,
416
+ }
417
+ for i, e in enumerate(act.edges)
418
+ ]
419
+ return {
420
+ "nodes": nodes,
421
+ "edges": edges,
422
+ "meta": {
423
+ "count": len(nodes),
424
+ "name": act.name,
425
+ "granularity": act.granularity,
426
+ },
427
+ }
428
+
429
+
430
+ def build_change_list(project: Project) -> list[dict[str, Any]]:
431
+ """Class-level change summary for diff walkthrough.
432
+
433
+ Returns one entry per class whose status is not UNCHANGED. Each entry
434
+ carries the class's stable_id (so the canvas can fitView onto it) and a
435
+ bullet list of added / removed attribute & operation signatures so the
436
+ UI can highlight exactly what differs.
437
+
438
+ Empty for a non-diff XMI.
439
+ """
440
+ out: list[dict[str, Any]] = []
441
+ seen_ids: set[str] = set()
442
+ for cls in project.iter_classes():
443
+ if cls.status == DiffStatus.UNCHANGED:
444
+ continue
445
+ if cls.stable_id() in seen_ids:
446
+ continue
447
+ seen_ids.add(cls.stable_id())
448
+ members: list[dict[str, Any]] = []
449
+ added_a = 0
450
+ removed_a = 0
451
+ added_o = 0
452
+ removed_o = 0
453
+ for a in cls.attributes:
454
+ if a.status == DiffStatus.ADDED:
455
+ added_a += 1
456
+ members.append({"kind": "attribute", "signature": a.signature(), "status": "added"})
457
+ elif a.status == DiffStatus.REMOVED:
458
+ removed_a += 1
459
+ members.append({"kind": "attribute", "signature": a.signature(), "status": "removed"})
460
+ for o in cls.operations:
461
+ if o.status == DiffStatus.ADDED:
462
+ added_o += 1
463
+ members.append({"kind": "operation", "signature": o.signature(), "status": "added"})
464
+ elif o.status == DiffStatus.REMOVED:
465
+ removed_o += 1
466
+ members.append({"kind": "operation", "signature": o.signature(), "status": "removed"})
467
+
468
+ kind = (
469
+ "added"
470
+ if cls.status == DiffStatus.ADDED
471
+ else "removed"
472
+ if cls.status == DiffStatus.REMOVED
473
+ else "changed"
474
+ )
475
+ bits = []
476
+ if added_a: bits.append(f"+{added_a} attr{'s' if added_a != 1 else ''}")
477
+ if removed_a: bits.append(f"-{removed_a} attr{'s' if removed_a != 1 else ''}")
478
+ if added_o: bits.append(f"+{added_o} op{'s' if added_o != 1 else ''}")
479
+ if removed_o: bits.append(f"-{removed_o} op{'s' if removed_o != 1 else ''}")
480
+ summary = (
481
+ f"{cls.name}: {kind}"
482
+ if not bits
483
+ else f"{cls.name}: {', '.join(bits)}"
484
+ )
485
+
486
+ out.append({
487
+ "classId": cls.stable_id(),
488
+ "classQname": cls.qualified_name,
489
+ "kind": kind,
490
+ "summary": summary,
491
+ "members": members,
492
+ })
493
+
494
+ # Order: changed classes first (most interesting), then added, then removed,
495
+ # alphabetised within each group by qualified name.
496
+ rank = {"changed": 0, "added": 1, "removed": 2}
497
+ out.sort(key=lambda e: (rank.get(e["kind"], 9), e["classQname"]))
498
+ return out
499
+
500
+
501
+ def build_sequence_graph(project: Project, name: str) -> dict[str, Any]:
502
+ """Lifeline / message graph for one sequence diagram.
503
+
504
+ The frontend lays this out by `column` (lifeline index) and `row` (message
505
+ index); this builder stays geometry-agnostic. Removed (ghost) lifelines
506
+ are placed after surviving ones in declared order so their column index
507
+ stays stable; removed messages keep their position in the diff'd `messages`
508
+ list (which is already how `_diff_sequence` arranges them).
509
+ """
510
+ seq = next((s for s in project.sequences if s.name == name), None)
511
+ if seq is None:
512
+ raise KeyError(f"sequence {name!r} not found")
513
+
514
+ lifelines: list[dict[str, Any]] = []
515
+ lifeline_id_by_name: dict[str, str] = {}
516
+ for col, ll in enumerate(seq.lifelines):
517
+ lid = f"ll-{_seq_safe(ll.name)}"
518
+ lifeline_id_by_name[ll.name] = lid
519
+ lifelines.append({
520
+ "id": lid,
521
+ "name": ll.name,
522
+ "represents": ll.represents,
523
+ "column": col,
524
+ "status": ll.status.value,
525
+ })
526
+
527
+ messages: list[dict[str, Any]] = []
528
+ for row, m in enumerate(seq.messages):
529
+ messages.append({
530
+ "id": f"msg-{row}",
531
+ "sender": lifeline_id_by_name.get(m.sender, f"ll-{_seq_safe(m.sender)}"),
532
+ "receiver": lifeline_id_by_name.get(m.receiver, f"ll-{_seq_safe(m.receiver)}"),
533
+ "label": m.label,
534
+ "isReturn": m.is_return,
535
+ "guard": m.guard,
536
+ "row": row,
537
+ "status": m.status.value,
538
+ })
539
+
540
+ fragments = [
541
+ {
542
+ "id": f"frag-{i}",
543
+ "kind": f.kind,
544
+ "label": f.label,
545
+ "startRow": f.start_row,
546
+ "endRow": f.end_row,
547
+ "status": f.status.value,
548
+ }
549
+ for i, f in enumerate(seq.fragments)
550
+ ]
551
+
552
+ return {
553
+ "lifelines": lifelines,
554
+ "messages": messages,
555
+ "fragments": fragments,
556
+ "meta": {
557
+ "name": seq.name,
558
+ "lifelineCount": len(lifelines),
559
+ "messageCount": len(messages),
560
+ "fragmentCount": len(fragments),
561
+ },
562
+ }
563
+
564
+
565
+ def build_activity_change_list(project: Project) -> list[dict[str, Any]]:
566
+ """Per-activity diff summary for the activity walkthrough.
567
+
568
+ Returns one entry per activity whose status is not UNCHANGED, ordered
569
+ changed → added → removed → alphabetised within group. Each entry lists
570
+ added/removed node and edge signatures so the panel can render
571
+ bullets and the canvas can fitView onto a specific node.
572
+ """
573
+ out: list[dict[str, Any]] = []
574
+ for act in project.activities:
575
+ if act.status == DiffStatus.UNCHANGED:
576
+ continue
577
+ added_n = removed_n = added_e = removed_e = 0
578
+ members: list[dict[str, Any]] = []
579
+ for n in act.nodes:
580
+ if n.status == DiffStatus.ADDED:
581
+ added_n += 1
582
+ members.append({"kind": "node", "nodeId": n.id, "signature": f"{n.kind}: {n.label or '(unlabelled)'}", "status": "added"})
583
+ elif n.status == DiffStatus.REMOVED:
584
+ removed_n += 1
585
+ members.append({"kind": "node", "nodeId": n.id, "signature": f"{n.kind}: {n.label or '(unlabelled)'}", "status": "removed"})
586
+ for e in act.edges:
587
+ if e.status == DiffStatus.ADDED:
588
+ added_e += 1
589
+ guard = f" [{e.guard}]" if e.guard else ""
590
+ members.append({"kind": "edge", "signature": f"{e.source} → {e.target}{guard}", "status": "added"})
591
+ elif e.status == DiffStatus.REMOVED:
592
+ removed_e += 1
593
+ guard = f" [{e.guard}]" if e.guard else ""
594
+ members.append({"kind": "edge", "signature": f"{e.source} → {e.target}{guard}", "status": "removed"})
595
+
596
+ kind = (
597
+ "added" if act.status == DiffStatus.ADDED
598
+ else "removed" if act.status == DiffStatus.REMOVED
599
+ else "changed"
600
+ )
601
+ bits = []
602
+ if added_n: bits.append(f"+{added_n} node{'s' if added_n != 1 else ''}")
603
+ if removed_n: bits.append(f"-{removed_n} node{'s' if removed_n != 1 else ''}")
604
+ if added_e: bits.append(f"+{added_e} edge{'s' if added_e != 1 else ''}")
605
+ if removed_e: bits.append(f"-{removed_e} edge{'s' if removed_e != 1 else ''}")
606
+ summary = f"{act.name}: {kind}" if not bits else f"{act.name}: {', '.join(bits)}"
607
+
608
+ out.append({
609
+ "diagramKind": "activity",
610
+ "diagramName": act.name,
611
+ "diagramId": act.stable_id(),
612
+ "kind": kind,
613
+ "summary": summary,
614
+ "members": members,
615
+ })
616
+
617
+ rank = {"changed": 0, "added": 1, "removed": 2}
618
+ out.sort(key=lambda e: (rank.get(e["kind"], 9), e["diagramName"]))
619
+ return out
620
+
621
+
622
+ def build_sequence_change_list(project: Project) -> list[dict[str, Any]]:
623
+ """Per-sequence diff summary mirroring the activity change-list shape."""
624
+ out: list[dict[str, Any]] = []
625
+ for seq in project.sequences:
626
+ if seq.status == DiffStatus.UNCHANGED:
627
+ continue
628
+ added_ll = removed_ll = added_m = removed_m = 0
629
+ members: list[dict[str, Any]] = []
630
+ for ll in seq.lifelines:
631
+ if ll.status == DiffStatus.ADDED:
632
+ added_ll += 1
633
+ members.append({"kind": "lifeline", "signature": ll.name, "status": "added"})
634
+ elif ll.status == DiffStatus.REMOVED:
635
+ removed_ll += 1
636
+ members.append({"kind": "lifeline", "signature": ll.name, "status": "removed"})
637
+ for i, m in enumerate(seq.messages):
638
+ guard_prefix = f"[{m.guard}] " if m.guard else ""
639
+ sig = f"{m.sender} → {m.receiver}: {guard_prefix}{m.label}"
640
+ if m.status == DiffStatus.ADDED:
641
+ added_m += 1
642
+ members.append({"kind": "message", "messageRow": i, "signature": sig, "status": "added"})
643
+ elif m.status == DiffStatus.REMOVED:
644
+ removed_m += 1
645
+ members.append({"kind": "message", "messageRow": i, "signature": sig, "status": "removed"})
646
+
647
+ kind = (
648
+ "added" if seq.status == DiffStatus.ADDED
649
+ else "removed" if seq.status == DiffStatus.REMOVED
650
+ else "changed"
651
+ )
652
+ bits = []
653
+ if added_ll: bits.append(f"+{added_ll} lifeline{'s' if added_ll != 1 else ''}")
654
+ if removed_ll: bits.append(f"-{removed_ll} lifeline{'s' if removed_ll != 1 else ''}")
655
+ if added_m: bits.append(f"+{added_m} msg{'s' if added_m != 1 else ''}")
656
+ if removed_m: bits.append(f"-{removed_m} msg{'s' if removed_m != 1 else ''}")
657
+ summary = f"{seq.name}: {kind}" if not bits else f"{seq.name}: {', '.join(bits)}"
658
+
659
+ out.append({
660
+ "diagramKind": "sequence",
661
+ "diagramName": seq.name,
662
+ "diagramId": seq.stable_id(),
663
+ "kind": kind,
664
+ "summary": summary,
665
+ "members": members,
666
+ })
667
+
668
+ rank = {"changed": 0, "added": 1, "removed": 2}
669
+ out.sort(key=lambda e: (rank.get(e["kind"], 9), e["diagramName"]))
670
+ return out
671
+
672
+
673
+ __all__ = [
674
+ "build_class_graph",
675
+ "build_package_graph",
676
+ "build_activity_graph",
677
+ "build_sequence_graph",
678
+ "build_change_list",
679
+ "build_activity_change_list",
680
+ "build_sequence_change_list",
681
+ ]