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,257 @@
|
|
|
1
|
+
"""Build a UML `Sequence` from a tagged Python region.
|
|
2
|
+
|
|
3
|
+
Three semantics worth pinning down:
|
|
4
|
+
|
|
5
|
+
1. **Call evaluation order is preserved.** Calls within a single statement
|
|
6
|
+
are emitted innermost-first (post-order), matching Python's evaluation
|
|
7
|
+
order. For `cart.checkout(cart.total())` we emit `total()` before
|
|
8
|
+
`checkout()` — the argument is computed before the outer dispatch.
|
|
9
|
+
|
|
10
|
+
2. **Each call has a paired return arrow.** Right after the forward
|
|
11
|
+
`root → receiver` message we emit a dashed `receiver → root` return
|
|
12
|
+
message. The return is anonymous (empty label) by default; when the
|
|
13
|
+
call is the *outermost* call in an `Assign` statement we use the
|
|
14
|
+
assignment target as the return label (e.g. `result = cart.checkout(...)`
|
|
15
|
+
produces a return labelled `result`).
|
|
16
|
+
|
|
17
|
+
3. **Branches become combined fragments + guard prefixes.** For
|
|
18
|
+
`if cond: ...` we attach `cond` to every message inside the body as a
|
|
19
|
+
guard string AND emit a `Fragment(kind="alt", label=cond, …)` covering
|
|
20
|
+
the body's message rows; the `else` branch gets a sibling fragment with
|
|
21
|
+
the negation. `return X` at the top level emits a self-return on the
|
|
22
|
+
root with label `return X`, giving the diagram an explicit endpoint.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import ast
|
|
28
|
+
|
|
29
|
+
from code_constraints.core.model import Fragment, Lifeline, Message, Sequence, SourceLocation
|
|
30
|
+
from code_constraints.core.tags import TagInstance
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_sequence_from_tag(
|
|
34
|
+
tag: TagInstance, tree: ast.Module, source: str, *, file: str
|
|
35
|
+
) -> Sequence | None:
|
|
36
|
+
if not tag.name:
|
|
37
|
+
return None
|
|
38
|
+
root = tag.attributes.get("root", "self")
|
|
39
|
+
|
|
40
|
+
stmts = _statements_in_range(tree, tag.start_line, tag.end_line)
|
|
41
|
+
if not stmts:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
state = _State(root=root)
|
|
45
|
+
_emit_messages(stmts, [], state)
|
|
46
|
+
|
|
47
|
+
return Sequence(
|
|
48
|
+
name=tag.name,
|
|
49
|
+
lifelines=list(state.lifelines.values()),
|
|
50
|
+
messages=state.messages,
|
|
51
|
+
fragments=state.fragments,
|
|
52
|
+
location=SourceLocation(file=file, start_line=tag.start_line, end_line=tag.end_line),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _State:
|
|
57
|
+
"""Mutable accumulator threaded through the recursive emitter."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, root: str) -> None:
|
|
60
|
+
self.root = root
|
|
61
|
+
self.lifelines: dict[str, Lifeline] = {root: Lifeline(name=root, represents=root)}
|
|
62
|
+
self.messages: list[Message] = []
|
|
63
|
+
self.fragments: list[Fragment] = []
|
|
64
|
+
|
|
65
|
+
def ensure_lifeline(self, name: str) -> None:
|
|
66
|
+
if name not in self.lifelines:
|
|
67
|
+
self.lifelines[name] = Lifeline(name=name, represents=name)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _emit_messages(
|
|
71
|
+
stmts: list[ast.stmt],
|
|
72
|
+
active_guards: list[str],
|
|
73
|
+
state: _State,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Walk top-level `stmts`, dispatching ifs/returns into special handlers
|
|
76
|
+
and letting any other statement contribute its calls in post-order."""
|
|
77
|
+
guard_str = " and ".join(active_guards)
|
|
78
|
+
for stmt in stmts:
|
|
79
|
+
if isinstance(stmt, ast.If):
|
|
80
|
+
_emit_if(stmt, active_guards, state)
|
|
81
|
+
elif isinstance(stmt, ast.Return):
|
|
82
|
+
_emit_calls_in(stmt.value, guard_str, state, return_label="")
|
|
83
|
+
value_text = _safe_unparse(stmt.value) if stmt.value is not None else ""
|
|
84
|
+
label = f"return {value_text}" if value_text else "return"
|
|
85
|
+
state.messages.append(
|
|
86
|
+
Message(
|
|
87
|
+
sender=state.root,
|
|
88
|
+
receiver=state.root,
|
|
89
|
+
label=label,
|
|
90
|
+
is_return=True,
|
|
91
|
+
guard=guard_str,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
elif isinstance(stmt, ast.Assign):
|
|
95
|
+
target = ""
|
|
96
|
+
if len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
|
|
97
|
+
target = stmt.targets[0].id
|
|
98
|
+
_emit_calls_in(stmt.value, guard_str, state, return_label=target)
|
|
99
|
+
elif isinstance(stmt, ast.AnnAssign):
|
|
100
|
+
target = ""
|
|
101
|
+
if isinstance(stmt.target, ast.Name):
|
|
102
|
+
target = stmt.target.id
|
|
103
|
+
if stmt.value is not None:
|
|
104
|
+
_emit_calls_in(stmt.value, guard_str, state, return_label=target)
|
|
105
|
+
else:
|
|
106
|
+
_emit_calls_in(stmt, guard_str, state, return_label="")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _emit_if(stmt: ast.If, active_guards: list[str], state: _State) -> None:
|
|
110
|
+
cond_text = _safe_unparse(stmt.test)
|
|
111
|
+
# Calls in the condition execute unconditionally — keep the current guards.
|
|
112
|
+
_emit_calls_in(stmt.test, " and ".join(active_guards), state, return_label="")
|
|
113
|
+
|
|
114
|
+
# Body: capture row range so we can emit a fragment covering it.
|
|
115
|
+
body_start = len(state.messages)
|
|
116
|
+
_emit_messages(stmt.body, active_guards + [cond_text], state)
|
|
117
|
+
body_end = len(state.messages) - 1
|
|
118
|
+
if body_end >= body_start:
|
|
119
|
+
state.fragments.append(
|
|
120
|
+
Fragment(
|
|
121
|
+
kind="alt" if stmt.orelse else "opt",
|
|
122
|
+
label=cond_text,
|
|
123
|
+
start_row=body_start,
|
|
124
|
+
end_row=body_end,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if stmt.orelse:
|
|
129
|
+
else_start = len(state.messages)
|
|
130
|
+
_emit_messages(stmt.orelse, active_guards + [f"not ({cond_text})"], state)
|
|
131
|
+
else_end = len(state.messages) - 1
|
|
132
|
+
if else_end >= else_start:
|
|
133
|
+
state.fragments.append(
|
|
134
|
+
Fragment(
|
|
135
|
+
kind="alt",
|
|
136
|
+
label=f"else ({cond_text} is false)",
|
|
137
|
+
start_row=else_start,
|
|
138
|
+
end_row=else_end,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _emit_calls_in(
|
|
144
|
+
node: ast.AST | None,
|
|
145
|
+
guard: str,
|
|
146
|
+
state: _State,
|
|
147
|
+
*,
|
|
148
|
+
return_label: str,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Emit forward + return messages for every Call inside `node`.
|
|
151
|
+
|
|
152
|
+
`return_label` decorates the return of the OUTERMOST call only (the one
|
|
153
|
+
yielded last by post-order). Inner calls always get an anonymous return.
|
|
154
|
+
"""
|
|
155
|
+
if node is None:
|
|
156
|
+
return
|
|
157
|
+
calls = list(_calls_post_order(node))
|
|
158
|
+
if not calls:
|
|
159
|
+
return
|
|
160
|
+
last_idx = len(calls) - 1
|
|
161
|
+
for i, call in enumerate(calls):
|
|
162
|
+
rl = return_label if i == last_idx else ""
|
|
163
|
+
_emit_call(call, state.root, guard, state, return_label=rl)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _emit_call(
|
|
167
|
+
call: ast.Call,
|
|
168
|
+
root: str,
|
|
169
|
+
guard: str,
|
|
170
|
+
state: _State,
|
|
171
|
+
*,
|
|
172
|
+
return_label: str,
|
|
173
|
+
) -> None:
|
|
174
|
+
sender, receiver, label = _resolve_call(call, root)
|
|
175
|
+
state.ensure_lifeline(receiver)
|
|
176
|
+
state.ensure_lifeline(sender)
|
|
177
|
+
state.messages.append(
|
|
178
|
+
Message(sender=sender, receiver=receiver, label=label, guard=guard)
|
|
179
|
+
)
|
|
180
|
+
state.messages.append(
|
|
181
|
+
Message(
|
|
182
|
+
sender=receiver,
|
|
183
|
+
receiver=sender,
|
|
184
|
+
label=return_label,
|
|
185
|
+
is_return=True,
|
|
186
|
+
guard=guard,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _calls_post_order(node: ast.AST):
|
|
192
|
+
"""Yield Call nodes inside `node` in inner-first (post-order) order."""
|
|
193
|
+
for child in ast.iter_child_nodes(node):
|
|
194
|
+
yield from _calls_post_order(child)
|
|
195
|
+
if isinstance(node, ast.Call):
|
|
196
|
+
yield node
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _safe_unparse(node: ast.AST) -> str:
|
|
200
|
+
try:
|
|
201
|
+
return ast.unparse(node)
|
|
202
|
+
except Exception:
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _statements_in_range(tree: ast.Module, start: int, end: int) -> list[ast.stmt]:
|
|
207
|
+
out: list[ast.stmt] = []
|
|
208
|
+
candidates: list[tuple[int, list[ast.stmt]]] = []
|
|
209
|
+
for node in ast.walk(tree):
|
|
210
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
211
|
+
f_start = getattr(node, "lineno", 0)
|
|
212
|
+
if not node.body:
|
|
213
|
+
continue
|
|
214
|
+
body_end = max(
|
|
215
|
+
(getattr(s, "end_lineno", getattr(s, "lineno", 0)) or 0)
|
|
216
|
+
for s in node.body
|
|
217
|
+
)
|
|
218
|
+
if f_start < start and body_end >= start:
|
|
219
|
+
stmts = [s for s in node.body if start < getattr(s, "lineno", 0) < end]
|
|
220
|
+
if stmts:
|
|
221
|
+
candidates.append((f_start, stmts))
|
|
222
|
+
if candidates:
|
|
223
|
+
candidates.sort(key=lambda c: c[0])
|
|
224
|
+
return candidates[-1][1]
|
|
225
|
+
for stmt in tree.body:
|
|
226
|
+
s_start = getattr(stmt, "lineno", 0)
|
|
227
|
+
if start < s_start < end:
|
|
228
|
+
out.append(stmt)
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _resolve_call(call: ast.Call, root: str) -> tuple[str, str, str]:
|
|
233
|
+
func = call.func
|
|
234
|
+
if isinstance(func, ast.Attribute):
|
|
235
|
+
receiver_name = _receiver_name(func.value, root)
|
|
236
|
+
label = func.attr + "()"
|
|
237
|
+
return root, receiver_name, label
|
|
238
|
+
if isinstance(func, ast.Name):
|
|
239
|
+
return root, "module", f"{func.id}()"
|
|
240
|
+
try:
|
|
241
|
+
return root, "module", f"{ast.unparse(func)}()"
|
|
242
|
+
except Exception:
|
|
243
|
+
return root, "module", "call()"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _receiver_name(node: ast.AST, root: str) -> str:
|
|
247
|
+
if isinstance(node, ast.Name):
|
|
248
|
+
return node.id
|
|
249
|
+
if isinstance(node, ast.Attribute):
|
|
250
|
+
try:
|
|
251
|
+
return ast.unparse(node)
|
|
252
|
+
except Exception:
|
|
253
|
+
return node.attr
|
|
254
|
+
try:
|
|
255
|
+
return ast.unparse(node)
|
|
256
|
+
except Exception:
|
|
257
|
+
return "obj"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The architecture-reference gate.
|
|
2
|
+
|
|
3
|
+
A dedicated structural comparator (decoupled from `code_constraints.core.diff`
|
|
4
|
+
and from the `code_constraints.lint` / `code_constraints.enforce` engines) that
|
|
5
|
+
reports every structural deviation of the current code from a stored reference
|
|
6
|
+
model. Driven by the `reference-architecture` rule type in `.cdec/rules.yaml`,
|
|
7
|
+
which is what `cdec check` runs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from code_constraints.reference.compare import Deviation, compare_to_reference
|
|
13
|
+
from code_constraints.reference.report import format_human, to_json
|
|
14
|
+
|
|
15
|
+
__all__ = ["Deviation", "compare_to_reference", "format_human", "to_json"]
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Structural comparator for the `reference-architecture` rule.
|
|
2
|
+
|
|
3
|
+
Decoupled from `code_constraints.core.diff` on purpose. `diff_projects` matches members by
|
|
4
|
+
`signature()` and only marks a matched member CHANGED when its *rule tags* differ,
|
|
5
|
+
so it is blind to visibility / static / abstract / readonly changes and to class
|
|
6
|
+
`kind` changes. The reference gate must reject all of those, so it walks both
|
|
7
|
+
`Project`s itself and reports every structural deviation as a `Deviation`.
|
|
8
|
+
|
|
9
|
+
Matching strategy (chosen so the human messages read as "changed", not
|
|
10
|
+
"removed + added"):
|
|
11
|
+
- classes -> by qualified name
|
|
12
|
+
- attributes -> by name within the class
|
|
13
|
+
- operations -> by name within the class, with full-signature fallback for
|
|
14
|
+
overload groups (C# can declare several operations per name)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from code_constraints.core.model import Attribute, Class, Operation, Project
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Deviation:
|
|
26
|
+
"""A single structural difference between the reference and current code.
|
|
27
|
+
|
|
28
|
+
`category` is a stable machine id (e.g. "attribute-changed"); `member` is the
|
|
29
|
+
attribute/operation signature when the deviation is member-level, else None.
|
|
30
|
+
`old` / `new` carry the differing values for machine consumers (None for pure
|
|
31
|
+
add/remove deviations).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
category: str
|
|
35
|
+
qualified_name: str
|
|
36
|
+
message: str
|
|
37
|
+
member: str | None = None
|
|
38
|
+
old: str | None = None
|
|
39
|
+
new: str | None = None
|
|
40
|
+
|
|
41
|
+
def sort_key(self) -> tuple[str, str, str]:
|
|
42
|
+
return (self.qualified_name, self.member or "", self.category)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compare_to_reference(reference: Project, current: Project) -> list[Deviation]:
|
|
46
|
+
"""Return every structural deviation of `current` from `reference`.
|
|
47
|
+
|
|
48
|
+
Raises ValueError when the two projects are in different source languages
|
|
49
|
+
(matching `diff_projects`' guard), since a cross-language comparison is
|
|
50
|
+
meaningless.
|
|
51
|
+
"""
|
|
52
|
+
if reference.source_language != current.source_language:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"cannot compare across languages: "
|
|
55
|
+
f"{reference.source_language} -> {current.source_language}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
out: list[Deviation] = []
|
|
59
|
+
ref_by_qn = {c.qualified_name: c for c in reference.iter_classes()}
|
|
60
|
+
cur_by_qn = {c.qualified_name: c for c in current.iter_classes()}
|
|
61
|
+
|
|
62
|
+
for qn, cur_cls in cur_by_qn.items():
|
|
63
|
+
if qn not in ref_by_qn:
|
|
64
|
+
out.append(
|
|
65
|
+
Deviation(
|
|
66
|
+
category="class-added",
|
|
67
|
+
qualified_name=qn,
|
|
68
|
+
message=f"Class '{qn}' was added.",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
out.extend(_compare_class(ref_by_qn[qn], cur_cls))
|
|
73
|
+
|
|
74
|
+
for qn in ref_by_qn:
|
|
75
|
+
if qn not in cur_by_qn:
|
|
76
|
+
out.append(
|
|
77
|
+
Deviation(
|
|
78
|
+
category="class-removed",
|
|
79
|
+
qualified_name=qn,
|
|
80
|
+
message=f"Class '{qn}' was removed.",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
out.sort(key=Deviation.sort_key)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _compare_class(ref: Class, cur: Class) -> list[Deviation]:
|
|
89
|
+
out: list[Deviation] = []
|
|
90
|
+
qn = cur.qualified_name
|
|
91
|
+
|
|
92
|
+
if ref.kind != cur.kind:
|
|
93
|
+
out.append(
|
|
94
|
+
Deviation(
|
|
95
|
+
category="class-kind-changed",
|
|
96
|
+
qualified_name=qn,
|
|
97
|
+
message=f"Class '{qn}' kind changed from {ref.kind} to {cur.kind}.",
|
|
98
|
+
old=ref.kind,
|
|
99
|
+
new=cur.kind,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
ref_bases, cur_bases = sorted(ref.bases), sorted(cur.bases)
|
|
104
|
+
if ref_bases != cur_bases:
|
|
105
|
+
out.append(
|
|
106
|
+
Deviation(
|
|
107
|
+
category="class-bases-changed",
|
|
108
|
+
qualified_name=qn,
|
|
109
|
+
message=(
|
|
110
|
+
f"Class '{qn}' base classes changed from "
|
|
111
|
+
f"[{', '.join(ref_bases)}] to [{', '.join(cur_bases)}]."
|
|
112
|
+
),
|
|
113
|
+
old=", ".join(ref_bases),
|
|
114
|
+
new=", ".join(cur_bases),
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
out.extend(_compare_attributes(qn, ref.attributes, cur.attributes))
|
|
119
|
+
out.extend(_compare_operations(qn, ref.operations, cur.operations))
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------- attributes ----------
|
|
124
|
+
|
|
125
|
+
def _compare_attributes(
|
|
126
|
+
qn: str, ref: list[Attribute], cur: list[Attribute]
|
|
127
|
+
) -> list[Deviation]:
|
|
128
|
+
out: list[Deviation] = []
|
|
129
|
+
ref_by_name = {a.name: a for a in ref}
|
|
130
|
+
cur_by_name = {a.name: a for a in cur}
|
|
131
|
+
|
|
132
|
+
for name, a in cur_by_name.items():
|
|
133
|
+
if name not in ref_by_name:
|
|
134
|
+
out.append(
|
|
135
|
+
Deviation(
|
|
136
|
+
category="attribute-added",
|
|
137
|
+
qualified_name=qn,
|
|
138
|
+
member=a.signature(),
|
|
139
|
+
message=f"Property '{a.signature()}' was added to '{qn}'.",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
out.extend(_compare_attribute_pair(qn, ref_by_name[name], a))
|
|
144
|
+
|
|
145
|
+
for name, a in ref_by_name.items():
|
|
146
|
+
if name not in cur_by_name:
|
|
147
|
+
out.append(
|
|
148
|
+
Deviation(
|
|
149
|
+
category="attribute-removed",
|
|
150
|
+
qualified_name=qn,
|
|
151
|
+
member=a.signature(),
|
|
152
|
+
message=f"Property '{a.signature()}' was removed from '{qn}'.",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _compare_attribute_pair(qn: str, ref: Attribute, cur: Attribute) -> list[Deviation]:
|
|
159
|
+
changes = _diff_fields(
|
|
160
|
+
ref,
|
|
161
|
+
cur,
|
|
162
|
+
[
|
|
163
|
+
("type", "type"),
|
|
164
|
+
("visibility", "access level"),
|
|
165
|
+
("is_static", "static"),
|
|
166
|
+
("is_readonly", "readonly"),
|
|
167
|
+
("default", "default value"),
|
|
168
|
+
],
|
|
169
|
+
)
|
|
170
|
+
if not changes:
|
|
171
|
+
return []
|
|
172
|
+
summary = "; ".join(desc for desc, _, _ in changes)
|
|
173
|
+
return [
|
|
174
|
+
Deviation(
|
|
175
|
+
category="attribute-changed",
|
|
176
|
+
qualified_name=qn,
|
|
177
|
+
member=cur.signature(),
|
|
178
|
+
message=f"Property '{cur.name}' on '{qn}' changed: {summary}.",
|
|
179
|
+
old="; ".join(f"{label}={old}" for label, old, _ in changes),
|
|
180
|
+
new="; ".join(f"{label}={new}" for label, _, new in changes),
|
|
181
|
+
)
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------- operations ----------
|
|
186
|
+
|
|
187
|
+
def _compare_operations(
|
|
188
|
+
qn: str, ref: list[Operation], cur: list[Operation]
|
|
189
|
+
) -> list[Deviation]:
|
|
190
|
+
out: list[Deviation] = []
|
|
191
|
+
ref_by_name = _group_by_name(ref)
|
|
192
|
+
cur_by_name = _group_by_name(cur)
|
|
193
|
+
|
|
194
|
+
for name, cur_ops in cur_by_name.items():
|
|
195
|
+
ref_ops = ref_by_name.get(name)
|
|
196
|
+
if not ref_ops:
|
|
197
|
+
for op in cur_ops:
|
|
198
|
+
out.append(
|
|
199
|
+
Deviation(
|
|
200
|
+
category="operation-added",
|
|
201
|
+
qualified_name=qn,
|
|
202
|
+
member=op.signature(),
|
|
203
|
+
message=f"Method '{op.signature()}' was added to '{qn}'.",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
continue
|
|
207
|
+
if len(ref_ops) == 1 and len(cur_ops) == 1:
|
|
208
|
+
out.extend(_compare_operation_pair(qn, ref_ops[0], cur_ops[0]))
|
|
209
|
+
else:
|
|
210
|
+
out.extend(_compare_operation_overloads(qn, ref_ops, cur_ops))
|
|
211
|
+
|
|
212
|
+
for name, ref_ops in ref_by_name.items():
|
|
213
|
+
if name not in cur_by_name:
|
|
214
|
+
for op in ref_ops:
|
|
215
|
+
out.append(
|
|
216
|
+
Deviation(
|
|
217
|
+
category="operation-removed",
|
|
218
|
+
qualified_name=qn,
|
|
219
|
+
member=op.signature(),
|
|
220
|
+
message=f"Method '{op.signature()}' was removed from '{qn}'.",
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _compare_operation_overloads(
|
|
227
|
+
qn: str, ref_ops: list[Operation], cur_ops: list[Operation]
|
|
228
|
+
) -> list[Deviation]:
|
|
229
|
+
"""Overload group (>1 op sharing a name): match on full signature so we can
|
|
230
|
+
only report clean add/remove — a modifier-only change on one overload still
|
|
231
|
+
surfaces because the signature is unchanged yet the pair compares equal here,
|
|
232
|
+
so fall through to a per-signature modifier check."""
|
|
233
|
+
out: list[Deviation] = []
|
|
234
|
+
ref_by_sig = {o.signature(): o for o in ref_ops}
|
|
235
|
+
cur_by_sig = {o.signature(): o for o in cur_ops}
|
|
236
|
+
for sig, op in cur_by_sig.items():
|
|
237
|
+
if sig not in ref_by_sig:
|
|
238
|
+
out.append(
|
|
239
|
+
Deviation(
|
|
240
|
+
category="operation-added",
|
|
241
|
+
qualified_name=qn,
|
|
242
|
+
member=sig,
|
|
243
|
+
message=f"Method '{sig}' was added to '{qn}'.",
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
out.extend(_compare_operation_modifiers(qn, ref_by_sig[sig], op))
|
|
248
|
+
for sig, op in ref_by_sig.items():
|
|
249
|
+
if sig not in cur_by_sig:
|
|
250
|
+
out.append(
|
|
251
|
+
Deviation(
|
|
252
|
+
category="operation-removed",
|
|
253
|
+
qualified_name=qn,
|
|
254
|
+
member=sig,
|
|
255
|
+
message=f"Method '{sig}' was removed from '{qn}'.",
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _compare_operation_pair(qn: str, ref: Operation, cur: Operation) -> list[Deviation]:
|
|
262
|
+
out: list[Deviation] = []
|
|
263
|
+
ref_params = _params_repr(ref)
|
|
264
|
+
cur_params = _params_repr(cur)
|
|
265
|
+
if ref_params != cur_params:
|
|
266
|
+
out.append(
|
|
267
|
+
Deviation(
|
|
268
|
+
category="operation-signature-changed",
|
|
269
|
+
qualified_name=qn,
|
|
270
|
+
member=cur.signature(),
|
|
271
|
+
message=(
|
|
272
|
+
f"Method '{cur.name}' on '{qn}' signature changed from "
|
|
273
|
+
f"({ref_params}) to ({cur_params})."
|
|
274
|
+
),
|
|
275
|
+
old=ref_params,
|
|
276
|
+
new=cur_params,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
if ref.return_type != cur.return_type:
|
|
280
|
+
out.append(
|
|
281
|
+
Deviation(
|
|
282
|
+
category="operation-return-type-changed",
|
|
283
|
+
qualified_name=qn,
|
|
284
|
+
member=cur.signature(),
|
|
285
|
+
message=(
|
|
286
|
+
f"Method '{cur.name}' on '{qn}' return type changed from "
|
|
287
|
+
f"'{ref.return_type}' to '{cur.return_type}'."
|
|
288
|
+
),
|
|
289
|
+
old=ref.return_type,
|
|
290
|
+
new=cur.return_type,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
out.extend(_compare_operation_modifiers(qn, ref, cur))
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _compare_operation_modifiers(qn: str, ref: Operation, cur: Operation) -> list[Deviation]:
|
|
298
|
+
changes = _diff_fields(
|
|
299
|
+
ref,
|
|
300
|
+
cur,
|
|
301
|
+
[
|
|
302
|
+
("visibility", "access level"),
|
|
303
|
+
("is_static", "static"),
|
|
304
|
+
("is_abstract", "abstract"),
|
|
305
|
+
],
|
|
306
|
+
)
|
|
307
|
+
if not changes:
|
|
308
|
+
return []
|
|
309
|
+
summary = "; ".join(desc for desc, _, _ in changes)
|
|
310
|
+
return [
|
|
311
|
+
Deviation(
|
|
312
|
+
category="operation-modifier-changed",
|
|
313
|
+
qualified_name=qn,
|
|
314
|
+
member=cur.signature(),
|
|
315
|
+
message=f"Method '{cur.name}' on '{qn}' modifiers changed: {summary}.",
|
|
316
|
+
old="; ".join(f"{label}={old}" for label, old, _ in changes),
|
|
317
|
+
new="; ".join(f"{label}={new}" for label, _, new in changes),
|
|
318
|
+
)
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------- shared helpers ----------
|
|
323
|
+
|
|
324
|
+
def _diff_fields(ref, cur, fields: list[tuple[str, str]]):
|
|
325
|
+
"""Return [(human_desc, old_str, new_str)] for each field that differs.
|
|
326
|
+
|
|
327
|
+
`fields` is a list of (attr_name, label). Enum/None values are stringified.
|
|
328
|
+
"""
|
|
329
|
+
changes = []
|
|
330
|
+
for attr_name, label in fields:
|
|
331
|
+
old_val = _scalar(getattr(ref, attr_name))
|
|
332
|
+
new_val = _scalar(getattr(cur, attr_name))
|
|
333
|
+
if old_val != new_val:
|
|
334
|
+
changes.append((f"{label} {old_val} -> {new_val}", old_val, new_val))
|
|
335
|
+
return changes
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _scalar(value) -> str:
|
|
339
|
+
if value is None:
|
|
340
|
+
return "none"
|
|
341
|
+
# Visibility / other str-enums carry a readable .value.
|
|
342
|
+
return str(getattr(value, "value", value))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _params_repr(op: Operation) -> str:
|
|
346
|
+
return ", ".join(
|
|
347
|
+
f"{p.name}:{p.type}" + (f"={p.default}" if p.default is not None else "")
|
|
348
|
+
for p in op.parameters
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _group_by_name(ops: list[Operation]) -> dict[str, list[Operation]]:
|
|
353
|
+
grouped: dict[str, list[Operation]] = {}
|
|
354
|
+
for op in ops:
|
|
355
|
+
grouped.setdefault(op.name, []).append(op)
|
|
356
|
+
return grouped
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Human + JSON formatting for reference-gate deviations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from code_constraints.reference.compare import Deviation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_human(deviations: list[Deviation]) -> str:
|
|
9
|
+
"""Render deviations as a readable, CI-friendly report, grouped by class."""
|
|
10
|
+
if not deviations:
|
|
11
|
+
return "no deviations -- the codebase matches the reference.\n"
|
|
12
|
+
|
|
13
|
+
lines = [f"{len(deviations)} deviation(s) from the reference:"]
|
|
14
|
+
current_qn: str | None = None
|
|
15
|
+
for d in sorted(deviations, key=Deviation.sort_key):
|
|
16
|
+
if d.qualified_name != current_qn:
|
|
17
|
+
current_qn = d.qualified_name
|
|
18
|
+
lines.append(f"\n {current_qn}")
|
|
19
|
+
lines.append(f" - [{d.category}] {d.message}")
|
|
20
|
+
return "\n".join(lines) + "\n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_json(deviations: list[Deviation]) -> dict:
|
|
24
|
+
"""Machine-readable report for pipelines."""
|
|
25
|
+
return {
|
|
26
|
+
"deviation_count": len(deviations),
|
|
27
|
+
"deviations": [
|
|
28
|
+
{
|
|
29
|
+
"category": d.category,
|
|
30
|
+
"qualifiedName": d.qualified_name,
|
|
31
|
+
"member": d.member,
|
|
32
|
+
"message": d.message,
|
|
33
|
+
"old": d.old,
|
|
34
|
+
"new": d.new,
|
|
35
|
+
}
|
|
36
|
+
for d in sorted(deviations, key=Deviation.sort_key)
|
|
37
|
+
],
|
|
38
|
+
}
|