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,105 @@
|
|
|
1
|
+
"""Stable, human-quotable identifiers for a reported issue.
|
|
2
|
+
|
|
3
|
+
Every issue that any of the three engines reports carries a *key*: a short
|
|
4
|
+
token like ``V-1A2B3C4D`` that identifies the issue and nothing else. The key is
|
|
5
|
+
what makes the review workflow work — a human (or an agent) can read a report,
|
|
6
|
+
mark a line, and say "allow this one" without describing the violation again.
|
|
7
|
+
|
|
8
|
+
Two properties matter, and they drive the whole design:
|
|
9
|
+
|
|
10
|
+
* **Stable across runs.** Re-running a check over an unchanged tree must
|
|
11
|
+
produce identical keys, so a review file stays valid and an agent can round-
|
|
12
|
+
trip report → decision → `cdec baseline allow`.
|
|
13
|
+
* **Stable across unrelated edits.** The key is derived from the *identity* of
|
|
14
|
+
the issue (engine, rule, qualified name, discriminating detail) and never
|
|
15
|
+
from a file offset or line number. Inserting an import above a class must
|
|
16
|
+
not invalidate a waiver granted for that class.
|
|
17
|
+
|
|
18
|
+
The flip side of the second property is deliberate: a key names an *equivalence
|
|
19
|
+
class* of issues, not a physical line. Two identical violations of one rule on
|
|
20
|
+
one element share a key, and waiving it waives both. That is the intended
|
|
21
|
+
semantic — the reviewer is accepting a fact about the code, not a coordinate.
|
|
22
|
+
|
|
23
|
+
The prefix letter records which *engine* produced the issue, so a key alone is
|
|
24
|
+
enough to route it. Every engine now runs under the single `cdec check` command,
|
|
25
|
+
but the prefixes are unchanged — they name the engine, not the command, which is
|
|
26
|
+
what keeps a waiver written before the CLI was unified still valid after it:
|
|
27
|
+
|
|
28
|
+
V- configured architectural rules (Engine A — drift)
|
|
29
|
+
F- source-tag conformance (Engine B — implementation obeys its tags)
|
|
30
|
+
L- implementation locks (Engine C — frozen body changed)
|
|
31
|
+
R- reference-architecture gate (structural deviation from reference.xmi)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import re
|
|
38
|
+
|
|
39
|
+
# Engine name -> key prefix letter. Engine names are internal identities that
|
|
40
|
+
# outlive CLI command names, so a recorded waiver survives a CLI reshuffle.
|
|
41
|
+
ENGINE_PREFIX: dict[str, str] = {
|
|
42
|
+
"check": "V",
|
|
43
|
+
"enforce": "F",
|
|
44
|
+
"lock": "L",
|
|
45
|
+
"reference": "R",
|
|
46
|
+
}
|
|
47
|
+
PREFIX_ENGINE: dict[str, str] = {v: k for k, v in ENGINE_PREFIX.items()}
|
|
48
|
+
|
|
49
|
+
# 8 hex chars = 32 bits. At a thousand issues in one report the odds of any
|
|
50
|
+
# collision are ~1 in 8500, and a collision only ever merges two issues of the
|
|
51
|
+
# same engine into one waiver — annoying, never unsound.
|
|
52
|
+
_KEY_HEX_LEN = 8
|
|
53
|
+
|
|
54
|
+
KEY_RE = re.compile(r"\b([VFLR])-([0-9A-F]{%d})\b" % _KEY_HEX_LEN)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UnknownEngine(ValueError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def make_key(engine: str, rule: str, qualified_name: str, detail: str = "") -> str:
|
|
62
|
+
"""Compute the stable key for one issue.
|
|
63
|
+
|
|
64
|
+
`detail` is the per-engine discriminator that separates two issues of the
|
|
65
|
+
same rule on the same element — the `signature` for a lint violation, an
|
|
66
|
+
explicit detail string for a conformance finding. Pass "" when the rule can
|
|
67
|
+
only fire once per element.
|
|
68
|
+
"""
|
|
69
|
+
prefix = ENGINE_PREFIX.get(engine)
|
|
70
|
+
if prefix is None:
|
|
71
|
+
raise UnknownEngine(f"unknown engine: {engine!r}")
|
|
72
|
+
raw = "|".join((engine, rule, qualified_name, detail or ""))
|
|
73
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:_KEY_HEX_LEN]
|
|
74
|
+
return f"{prefix}-{digest.upper()}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def engine_of(key: str) -> str | None:
|
|
78
|
+
"""The engine that produced `key`, or None if it isn't a well-formed key."""
|
|
79
|
+
match = KEY_RE.fullmatch(key.strip())
|
|
80
|
+
if match is None:
|
|
81
|
+
return None
|
|
82
|
+
return PREFIX_ENGINE.get(match.group(1))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_key(text: str) -> bool:
|
|
86
|
+
return KEY_RE.fullmatch(text.strip()) is not None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_keys(text: str) -> list[str]:
|
|
90
|
+
"""Every key appearing in `text`, in order, de-duplicated."""
|
|
91
|
+
seen: list[str] = []
|
|
92
|
+
for match in KEY_RE.finditer(text):
|
|
93
|
+
key = match.group(0)
|
|
94
|
+
if key not in seen:
|
|
95
|
+
seen.append(key)
|
|
96
|
+
return seen
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_key(text: str) -> str:
|
|
100
|
+
"""Accept a key the way a human might retype it (lowercase hex, missing
|
|
101
|
+
hyphen) and return the canonical form. Returns "" if it isn't a key."""
|
|
102
|
+
candidate = text.strip().upper().replace(" ", "")
|
|
103
|
+
if candidate and "-" not in candidate and len(candidate) == _KEY_HEX_LEN + 1:
|
|
104
|
+
candidate = f"{candidate[0]}-{candidate[1:]}"
|
|
105
|
+
return candidate if is_key(candidate) else ""
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Language-agnostic UML data model.
|
|
2
|
+
|
|
3
|
+
All parsers (Python AST, C# tree-sitter) produce a `Project`; all consumers
|
|
4
|
+
(XMI writer, DOT emitter, diff engine) operate on `Project`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from hashlib import sha1
|
|
12
|
+
from typing import Iterator, Literal, get_args
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DiffStatus(str, Enum):
|
|
16
|
+
UNCHANGED = "unchanged"
|
|
17
|
+
ADDED = "added"
|
|
18
|
+
REMOVED = "removed"
|
|
19
|
+
CHANGED = "changed"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Visibility(str, Enum):
|
|
23
|
+
PUBLIC = "public"
|
|
24
|
+
PROTECTED = "protected"
|
|
25
|
+
PRIVATE = "private"
|
|
26
|
+
PACKAGE = "package"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ClassKind = Literal["class", "interface", "abstract", "enum", "struct", "record", "static"]
|
|
30
|
+
ActivityNodeKind = Literal[
|
|
31
|
+
"initial", "final", "action", "decision", "merge", "fork", "join"
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SourceLocation:
|
|
37
|
+
file: str
|
|
38
|
+
start_line: int
|
|
39
|
+
end_line: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Layout:
|
|
44
|
+
"""Optional persisted canvas layout for an element. `None` means the
|
|
45
|
+
renderer should auto-place the element. Coordinates are in canvas units
|
|
46
|
+
(pixels) with origin at top-left."""
|
|
47
|
+
x: float = 0.0
|
|
48
|
+
y: float = 0.0
|
|
49
|
+
width: float = 0.0
|
|
50
|
+
height: float = 0.0
|
|
51
|
+
collapsed: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class EdgeLayout:
|
|
56
|
+
"""Per-edge layout: a list of waypoints the edge routes through, in canvas
|
|
57
|
+
units. Empty means the renderer routes the edge itself."""
|
|
58
|
+
waypoints: list[tuple[float, float]] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class RuleAnnotation:
|
|
63
|
+
"""An architectural-rule tag attached to a Class or Operation.
|
|
64
|
+
|
|
65
|
+
`name` is the canonical catalog id (e.g. "no-instantiation"). `args` and
|
|
66
|
+
`kwargs` hold the decorator/attribute argument *source text* so the value
|
|
67
|
+
round-trips verbatim and dataclass equality gives exact diff comparison."""
|
|
68
|
+
name: str
|
|
69
|
+
args: list[str] = field(default_factory=list)
|
|
70
|
+
kwargs: dict[str, str] = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Parameter:
|
|
75
|
+
name: str
|
|
76
|
+
type: str = ""
|
|
77
|
+
default: str | None = None
|
|
78
|
+
|
|
79
|
+
def signature(self) -> str:
|
|
80
|
+
return f"{self.name}:{self.type}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Attribute:
|
|
85
|
+
name: str
|
|
86
|
+
type: str = ""
|
|
87
|
+
visibility: Visibility = Visibility.PUBLIC
|
|
88
|
+
is_static: bool = False
|
|
89
|
+
is_readonly: bool = False
|
|
90
|
+
default: str | None = None
|
|
91
|
+
description: str | None = None
|
|
92
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
93
|
+
|
|
94
|
+
def signature(self) -> str:
|
|
95
|
+
return f"{self.name}:{self.type}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Operation:
|
|
100
|
+
name: str
|
|
101
|
+
parameters: list[Parameter] = field(default_factory=list)
|
|
102
|
+
return_type: str = ""
|
|
103
|
+
visibility: Visibility = Visibility.PUBLIC
|
|
104
|
+
is_static: bool = False
|
|
105
|
+
is_abstract: bool = False
|
|
106
|
+
description: str | None = None
|
|
107
|
+
rules: list[RuleAnnotation] = field(default_factory=list)
|
|
108
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
109
|
+
|
|
110
|
+
def signature(self) -> str:
|
|
111
|
+
params = ",".join(p.signature() for p in self.parameters)
|
|
112
|
+
return f"{self.name}({params}):{self.return_type}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Class:
|
|
117
|
+
name: str
|
|
118
|
+
qualified_name: str
|
|
119
|
+
kind: ClassKind = "class"
|
|
120
|
+
attributes: list[Attribute] = field(default_factory=list)
|
|
121
|
+
operations: list[Operation] = field(default_factory=list)
|
|
122
|
+
bases: list[str] = field(default_factory=list)
|
|
123
|
+
location: SourceLocation | None = None
|
|
124
|
+
layout: Layout | None = None
|
|
125
|
+
description: str | None = None
|
|
126
|
+
rules: list[RuleAnnotation] = field(default_factory=list)
|
|
127
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
128
|
+
# Raw type-name strings referenced inside method bodies (no type
|
|
129
|
+
# resolution). Consumers resolve these via `associations.resolve_association`
|
|
130
|
+
# to draw "uses" edges and count incoming references. Empty for pre-existing
|
|
131
|
+
# XMI and for parsers that don't scan bodies.
|
|
132
|
+
dependencies: list[str] = field(default_factory=list)
|
|
133
|
+
|
|
134
|
+
def stable_id(self) -> str:
|
|
135
|
+
return _stable_id("Class", self.qualified_name)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class Package:
|
|
140
|
+
name: str
|
|
141
|
+
qualified_name: str
|
|
142
|
+
classes: list[Class] = field(default_factory=list)
|
|
143
|
+
sub_packages: list[Package] = field(default_factory=list)
|
|
144
|
+
layout: Layout | None = None
|
|
145
|
+
description: str | None = None
|
|
146
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
147
|
+
|
|
148
|
+
def stable_id(self) -> str:
|
|
149
|
+
return _stable_id("Package", self.qualified_name)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class ActivityNode:
|
|
154
|
+
id: str
|
|
155
|
+
kind: ActivityNodeKind
|
|
156
|
+
label: str = ""
|
|
157
|
+
layout: Layout | None = None
|
|
158
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class ActivityEdge:
|
|
163
|
+
source: str
|
|
164
|
+
target: str
|
|
165
|
+
guard: str = ""
|
|
166
|
+
edge_layout: EdgeLayout | None = None
|
|
167
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class Activity:
|
|
172
|
+
name: str
|
|
173
|
+
nodes: list[ActivityNode] = field(default_factory=list)
|
|
174
|
+
edges: list[ActivityEdge] = field(default_factory=list)
|
|
175
|
+
location: SourceLocation | None = None
|
|
176
|
+
granularity: Literal["control-flow", "statement", "calls"] = "control-flow"
|
|
177
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
178
|
+
|
|
179
|
+
def stable_id(self) -> str:
|
|
180
|
+
return _stable_id("Activity", self.name)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class Lifeline:
|
|
185
|
+
name: str
|
|
186
|
+
represents: str = "" # type name the lifeline represents
|
|
187
|
+
column_x: float | None = None # canvas-units; None = auto-layout
|
|
188
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass
|
|
192
|
+
class Message:
|
|
193
|
+
sender: str # lifeline name
|
|
194
|
+
receiver: str
|
|
195
|
+
label: str
|
|
196
|
+
is_return: bool = False
|
|
197
|
+
# Optional guard condition, e.g. "result == 'ok'" — set by the parser when
|
|
198
|
+
# the call sits inside an `if` branch so the renderer can prefix the
|
|
199
|
+
# message label with `[guard]` (UML combined-fragment shorthand).
|
|
200
|
+
guard: str = ""
|
|
201
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class Fragment:
|
|
206
|
+
"""A UML combined fragment covering a contiguous range of message rows.
|
|
207
|
+
|
|
208
|
+
`start_row` and `end_row` are inclusive indices into `Sequence.messages`.
|
|
209
|
+
`kind` mirrors the standard UML operators ("alt", "opt", "loop"); the
|
|
210
|
+
parsers currently emit "alt" per if-branch (one for the if, one for the
|
|
211
|
+
else if present) and reserve "opt" / "loop" for future extensions.
|
|
212
|
+
`label` is the guard / interaction-operator argument text — e.g. the
|
|
213
|
+
`if` condition for an alt fragment.
|
|
214
|
+
"""
|
|
215
|
+
kind: Literal["alt", "opt", "loop"]
|
|
216
|
+
label: str
|
|
217
|
+
start_row: int
|
|
218
|
+
end_row: int
|
|
219
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class Sequence:
|
|
224
|
+
name: str
|
|
225
|
+
lifelines: list[Lifeline] = field(default_factory=list)
|
|
226
|
+
messages: list[Message] = field(default_factory=list)
|
|
227
|
+
fragments: list[Fragment] = field(default_factory=list)
|
|
228
|
+
location: SourceLocation | None = None
|
|
229
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
230
|
+
|
|
231
|
+
def stable_id(self) -> str:
|
|
232
|
+
return _stable_id("Sequence", self.name)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class Association:
|
|
237
|
+
"""Explicit association between two classes (by qualified name).
|
|
238
|
+
|
|
239
|
+
Parsers do not produce these — attribute-typed references already cover the
|
|
240
|
+
code-derived case via `resolve_association`. The editor uses Association to
|
|
241
|
+
let users draw "bare" relationships that don't correspond to any field.
|
|
242
|
+
"""
|
|
243
|
+
source: str
|
|
244
|
+
target: str
|
|
245
|
+
name: str | None = None
|
|
246
|
+
source_multiplicity: str | None = None
|
|
247
|
+
target_multiplicity: str | None = None
|
|
248
|
+
source_role: str | None = None
|
|
249
|
+
target_role: str | None = None
|
|
250
|
+
status: DiffStatus = DiffStatus.UNCHANGED
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
SourceLanguage = Literal[
|
|
254
|
+
"python", "csharp", "typescript", "svelte", "odin", "lua", "julia"
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
# The single source of truth for "which languages does this build support".
|
|
258
|
+
# Derived from the type so the runtime check and the type check can never
|
|
259
|
+
# disagree; every dispatch site validates against this rather than repeating
|
|
260
|
+
# the tuple. Note that support is layered — a language always has a UML parser
|
|
261
|
+
# here, but `cdec enforce` and `cdec lock` additionally need a conformance
|
|
262
|
+
# analyzer and an AST fingerprinter (TypeScript and Svelte have neither yet, and
|
|
263
|
+
# say so when asked).
|
|
264
|
+
SUPPORTED_LANGUAGES: tuple[str, ...] = get_args(SourceLanguage)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass
|
|
268
|
+
class Project:
|
|
269
|
+
source_language: SourceLanguage
|
|
270
|
+
packages: list[Package] = field(default_factory=list)
|
|
271
|
+
activities: list[Activity] = field(default_factory=list)
|
|
272
|
+
sequences: list[Sequence] = field(default_factory=list)
|
|
273
|
+
associations: list[Association] = field(default_factory=list)
|
|
274
|
+
root_path: str = ""
|
|
275
|
+
|
|
276
|
+
def iter_classes(self) -> Iterator[Class]:
|
|
277
|
+
for pkg in _walk_packages(self.packages):
|
|
278
|
+
yield from pkg.classes
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _walk_packages(packages: list[Package]) -> Iterator[Package]:
|
|
282
|
+
for pkg in packages:
|
|
283
|
+
yield pkg
|
|
284
|
+
yield from _walk_packages(pkg.sub_packages)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _stable_id(kind: str, qualified_name: str) -> str:
|
|
288
|
+
"""Deterministic ID for cross-revision matching.
|
|
289
|
+
|
|
290
|
+
sha1 is used purely as a hash (no security implications); the prefix keeps
|
|
291
|
+
IDs readable in the XMI.
|
|
292
|
+
"""
|
|
293
|
+
h = sha1(f"{kind}|{qualified_name}".encode("utf-8")).hexdigest()[:16]
|
|
294
|
+
return f"{kind.lower()}-{h}"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Extension-dispatched model persistence.
|
|
2
|
+
|
|
3
|
+
The on-disk source of truth stays XMI 2.1, but a `Project` can equally be
|
|
4
|
+
stored as the editor-bridge JSON shape (`editor_io.project_to_json`) under a
|
|
5
|
+
`.json` extension. JSON is far easier for humans and AI agents to author and
|
|
6
|
+
review than XMI, so every CLI command that reads or writes a model file goes
|
|
7
|
+
through `load_model` / `save_model` instead of calling the XMI codec directly.
|
|
8
|
+
|
|
9
|
+
The JSON document is exactly the wire format the web editor uses — one schema,
|
|
10
|
+
three consumers (CLI files, editor drafts, proposal endpoint).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from code_constraints.core.editor_io import project_from_json, project_to_json
|
|
19
|
+
from code_constraints.core.model import Project
|
|
20
|
+
from code_constraints.core.xmi_reader import read_project
|
|
21
|
+
from code_constraints.core.xmi_writer import write_project
|
|
22
|
+
|
|
23
|
+
MODEL_SUFFIXES = (".xmi", ".json")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnsupportedModelFormat(ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _codec(path: Path) -> str:
|
|
31
|
+
suffix = path.suffix.lower()
|
|
32
|
+
if suffix not in MODEL_SUFFIXES:
|
|
33
|
+
raise UnsupportedModelFormat(
|
|
34
|
+
f"unsupported model file extension {suffix!r} for {path} "
|
|
35
|
+
f"(expected one of: {', '.join(MODEL_SUFFIXES)})"
|
|
36
|
+
)
|
|
37
|
+
return suffix
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_model(path: Path | str) -> Project:
|
|
41
|
+
"""Read a Project from `.xmi` (XMI 2.1) or `.json` (editor JSON)."""
|
|
42
|
+
path = Path(path)
|
|
43
|
+
if _codec(path) == ".json":
|
|
44
|
+
with path.open(encoding="utf-8") as fh:
|
|
45
|
+
data = json.load(fh)
|
|
46
|
+
if not isinstance(data, dict):
|
|
47
|
+
raise ValueError(f"{path}: top-level JSON value must be an object")
|
|
48
|
+
return project_from_json(data)
|
|
49
|
+
return read_project(path)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save_model(project: Project, path: Path | str) -> None:
|
|
53
|
+
"""Write a Project to `.xmi` or `.json`, chosen by the file extension."""
|
|
54
|
+
path = Path(path)
|
|
55
|
+
if _codec(path) == ".json":
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
path.write_text(
|
|
58
|
+
json.dumps(project_to_json(project), indent=2, sort_keys=False) + "\n",
|
|
59
|
+
encoding="utf-8",
|
|
60
|
+
)
|
|
61
|
+
return
|
|
62
|
+
write_project(project, path)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["load_model", "save_model", "MODEL_SUFFIXES", "UnsupportedModelFormat"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Receiver resolution for languages that declare operations outside their type.
|
|
2
|
+
|
|
3
|
+
Odin (`proc(inv: ^Invoice, …)`) and Julia (`f(inv::Invoice, …)`) both model an
|
|
4
|
+
operation's owner as the type of its first parameter. Three consumers need that
|
|
5
|
+
answer to agree exactly — the UML parser, the `cdec lock` fingerprinter, and the
|
|
6
|
+
`cdec enforce` analyzer — because a target name an agent reads from one has to be
|
|
7
|
+
the target the others accept. The lookup *policy* lives here so there is one
|
|
8
|
+
definition of it rather than three.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TypeVar
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_owner(
|
|
19
|
+
name: str, package_qn: str, index: dict[tuple[str, str], T]
|
|
20
|
+
) -> T | None:
|
|
21
|
+
"""Find the type `name` refers to from within `package_qn`.
|
|
22
|
+
|
|
23
|
+
Same package first — the normal case, and the only one a compiler would
|
|
24
|
+
resolve without imports. Falling back to a project-wide match by bare name
|
|
25
|
+
is the best a syntactic parse can do; it is deliberately skipped when the
|
|
26
|
+
name is ambiguous, so a wrong owner is never guessed.
|
|
27
|
+
"""
|
|
28
|
+
if not name:
|
|
29
|
+
return None
|
|
30
|
+
same_package = index.get((package_qn, name))
|
|
31
|
+
if same_package is not None:
|
|
32
|
+
return same_package
|
|
33
|
+
matches = [value for (_pkg, other), value in index.items() if other == name]
|
|
34
|
+
return matches[0] if len(matches) == 1 else None
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Canonical catalog of architectural-rule tags.
|
|
2
|
+
|
|
3
|
+
Single source of truth shared by the parsers (recognition), the web app
|
|
4
|
+
(presentation), and both enforcement engines (`cdec check` drift rules and the
|
|
5
|
+
`cdec enforce` conformance command). Add a rule here once; every other module
|
|
6
|
+
looks it up by id / python name / csharp name rather than hard-coding strings.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
Target = Literal["class", "operation"]
|
|
15
|
+
Enforcement = Literal["drift", "architectural", "implementation", "lock"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class RuleSpec:
|
|
20
|
+
id: str # canonical kebab-case id, e.g. "no-instantiation"
|
|
21
|
+
python_name: str # decorator name as imported from the shim, e.g. "no_instantiation"
|
|
22
|
+
csharp_name: str # attribute name without the "Attribute" suffix, e.g. "NoInstantiation"
|
|
23
|
+
targets: frozenset[Target]
|
|
24
|
+
params: tuple[str, ...] = () # recognised keyword parameter names
|
|
25
|
+
enforcement: Enforcement = "drift"
|
|
26
|
+
summary: str = ""
|
|
27
|
+
# Julia macro name without the leading `@`, e.g. "no_instantiation".
|
|
28
|
+
julia_name: str = ""
|
|
29
|
+
# Name used in the `---@cdec <name>(...)` / `//@cdec <name>(...)` annotation
|
|
30
|
+
# comments that carry tags in Lua and Odin (neither language has a
|
|
31
|
+
# user-extensible decorator/attribute syntax — see the annotation note below).
|
|
32
|
+
annotation_name: str = ""
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
# The Julia macro and the Lua/Odin annotation keyword both default to the
|
|
36
|
+
# Python decorator name; every language then spells one vocabulary.
|
|
37
|
+
if not self.julia_name:
|
|
38
|
+
object.__setattr__(self, "julia_name", self.python_name)
|
|
39
|
+
if not self.annotation_name:
|
|
40
|
+
object.__setattr__(self, "annotation_name", self.python_name)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_SPECS: tuple[RuleSpec, ...] = (
|
|
44
|
+
RuleSpec(
|
|
45
|
+
id="no-instantiation",
|
|
46
|
+
python_name="no_instantiation",
|
|
47
|
+
csharp_name="NoInstantiation",
|
|
48
|
+
targets=frozenset({"class", "operation"}),
|
|
49
|
+
params=("allow",),
|
|
50
|
+
enforcement="implementation",
|
|
51
|
+
summary="May not construct objects (except types listed in `allow`).",
|
|
52
|
+
),
|
|
53
|
+
RuleSpec(
|
|
54
|
+
id="no-side-effects",
|
|
55
|
+
python_name="no_side_effects",
|
|
56
|
+
csharp_name="NoSideEffects",
|
|
57
|
+
targets=frozenset({"operation"}),
|
|
58
|
+
params=("allow",),
|
|
59
|
+
enforcement="drift",
|
|
60
|
+
summary="Must be free of side effects (body analysis deferred; tag is captured + frozen).",
|
|
61
|
+
),
|
|
62
|
+
RuleSpec(
|
|
63
|
+
id="sealed",
|
|
64
|
+
python_name="sealed",
|
|
65
|
+
csharp_name="Sealed",
|
|
66
|
+
targets=frozenset({"class"}),
|
|
67
|
+
enforcement="implementation",
|
|
68
|
+
summary="May not be subclassed (composition over inheritance).",
|
|
69
|
+
),
|
|
70
|
+
RuleSpec(
|
|
71
|
+
id="immutable",
|
|
72
|
+
python_name="immutable",
|
|
73
|
+
csharp_name="Immutable",
|
|
74
|
+
targets=frozenset({"class"}),
|
|
75
|
+
enforcement="implementation",
|
|
76
|
+
summary="Fields may not be reassigned after construction.",
|
|
77
|
+
),
|
|
78
|
+
RuleSpec(
|
|
79
|
+
id="factory",
|
|
80
|
+
python_name="factory",
|
|
81
|
+
csharp_name="Factory",
|
|
82
|
+
targets=frozenset({"class", "operation"}),
|
|
83
|
+
params=("creates",),
|
|
84
|
+
enforcement="implementation",
|
|
85
|
+
summary="Designated constructor of the types in `creates`; instantiation elsewhere is forbidden.",
|
|
86
|
+
),
|
|
87
|
+
RuleSpec(
|
|
88
|
+
id="locked",
|
|
89
|
+
python_name="locked",
|
|
90
|
+
csharp_name="Locked",
|
|
91
|
+
targets=frozenset({"class", "operation"}),
|
|
92
|
+
params=("reason", "owner"),
|
|
93
|
+
enforcement="lock",
|
|
94
|
+
summary=(
|
|
95
|
+
"Implementation is frozen: any semantic change to the body fails "
|
|
96
|
+
"`cdec lock check` until a lead re-baselines it."
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
RuleSpec(
|
|
100
|
+
id="layer",
|
|
101
|
+
python_name="layer",
|
|
102
|
+
csharp_name="Layer",
|
|
103
|
+
targets=frozenset({"class"}),
|
|
104
|
+
params=("name",),
|
|
105
|
+
enforcement="architectural",
|
|
106
|
+
summary="Assigns the class to an architectural layer for dependency-direction checks.",
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
RULE_CATALOG: dict[str, RuleSpec] = {spec.id: spec for spec in _SPECS}
|
|
111
|
+
_BY_PYTHON: dict[str, RuleSpec] = {spec.python_name: spec for spec in _SPECS}
|
|
112
|
+
_BY_CSHARP: dict[str, RuleSpec] = {spec.csharp_name: spec for spec in _SPECS}
|
|
113
|
+
_BY_JULIA: dict[str, RuleSpec] = {spec.julia_name: spec for spec in _SPECS}
|
|
114
|
+
_BY_ANNOTATION: dict[str, RuleSpec] = {spec.annotation_name: spec for spec in _SPECS}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def by_id(rule_id: str) -> RuleSpec | None:
|
|
118
|
+
return RULE_CATALOG.get(rule_id)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def by_python_name(name: str) -> RuleSpec | None:
|
|
122
|
+
return _BY_PYTHON.get(name)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def by_csharp_name(name: str) -> RuleSpec | None:
|
|
126
|
+
"""Look up by attribute name, tolerating the optional `Attribute` suffix."""
|
|
127
|
+
spec = _BY_CSHARP.get(name)
|
|
128
|
+
if spec is None and name.endswith("Attribute"):
|
|
129
|
+
spec = _BY_CSHARP.get(name[: -len("Attribute")])
|
|
130
|
+
return spec
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def by_julia_name(name: str) -> RuleSpec | None:
|
|
134
|
+
"""Look up by Julia macro name, with or without the leading `@`."""
|
|
135
|
+
return _BY_JULIA.get(name.lstrip("@"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def by_annotation_name(name: str) -> RuleSpec | None:
|
|
139
|
+
"""Look up by the keyword used in a `@cdec` annotation comment (Lua, Odin)."""
|
|
140
|
+
return _BY_ANNOTATION.get(name)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Module names the Python shim is published under; the parser only treats a
|
|
144
|
+
# decorator as a rule when its base name was imported from one of these.
|
|
145
|
+
PYTHON_SHIM_MODULES: frozenset[str] = frozenset({"cdec_rules", "code_constraints.rules"})
|
|
146
|
+
# `using` namespace that gates C# attribute recognition.
|
|
147
|
+
CSHARP_SHIM_NAMESPACE = "CodeConstraints.Rules"
|
|
148
|
+
# Julia module the macro shim is published as; a `@macro` counts as a rule only
|
|
149
|
+
# when the file brings this module into scope (`using`/`import CdecRules`).
|
|
150
|
+
JULIA_SHIM_MODULES: frozenset[str] = frozenset({"CdecRules"})
|
|
151
|
+
|
|
152
|
+
# Lua and Odin have no user-extensible decorator or attribute syntax — Lua has
|
|
153
|
+
# no declaration modifiers at all, and the Odin compiler rejects any `@(...)`
|
|
154
|
+
# attribute it doesn't know, so a no-op `@(cdec_sealed)` would fail to build.
|
|
155
|
+
# Both therefore carry tags in a namespaced *annotation comment* placed directly
|
|
156
|
+
# above the declaration, in the slot a decorator would occupy:
|
|
157
|
+
#
|
|
158
|
+
# ---@cdec sealed -- Lua (LuaCATS-style `---@` comment)
|
|
159
|
+
# ---@cdec layer("domain")
|
|
160
|
+
# local Invoice = {}
|
|
161
|
+
#
|
|
162
|
+
# //@cdec sealed // Odin
|
|
163
|
+
# //@cdec layer("domain")
|
|
164
|
+
# Invoice :: struct { ... }
|
|
165
|
+
#
|
|
166
|
+
# The `@cdec` prefix is the namespace, so it plays the gating role that the shim
|
|
167
|
+
# import plays in Python/C#/Julia: an unrelated annotation can never false-match.
|
|
168
|
+
ANNOTATION_MARKER = "@cdec"
|
|
169
|
+
|
|
170
|
+
# Shim files copied into a target project by `cdec init`. They declare the tag
|
|
171
|
+
# vocabulary as no-op functions, which would otherwise parse as a module of
|
|
172
|
+
# operations and show up as a class in the user's own diagrams. Parsers skip
|
|
173
|
+
# them by name. (Python and C# need no entry: their shims declare only
|
|
174
|
+
# functions / attribute classes that never reach the model.)
|
|
175
|
+
SHIM_FILENAMES: frozenset[str] = frozenset(
|
|
176
|
+
{"cdec_rules.lua", "cdec_rules.odin", "CdecRules.jl"}
|
|
177
|
+
)
|