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,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
|
+
]
|