code-constraints 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. code_constraints/__init__.py +1 -0
  2. code_constraints/cli/__init__.py +0 -0
  3. code_constraints/cli/__main__.py +1555 -0
  4. code_constraints/cli/_assets/agents/cdec-architect.md +468 -0
  5. code_constraints/cli/_assets/agents/oop-refactor-architect.md +317 -0
  6. code_constraints/cli/_assets/shims/csharp/CodeConstraintsRules.cs +94 -0
  7. code_constraints/cli/_assets/shims/julia/CdecRules.jl +129 -0
  8. code_constraints/cli/_assets/shims/lua/cdec_rules.lua +92 -0
  9. code_constraints/cli/_assets/shims/odin/cdec_rules.odin +67 -0
  10. code_constraints/cli/_assets/shims/python/cdec_rules.py +94 -0
  11. code_constraints/cli/_assets/skills/cdec-architecture-loop/SKILL.md +152 -0
  12. code_constraints/cli/depstamp.py +118 -0
  13. code_constraints/cli/detect.py +77 -0
  14. code_constraints/cli/interactive.py +304 -0
  15. code_constraints/cli/scaffold.py +602 -0
  16. code_constraints/cli/update.py +157 -0
  17. code_constraints/core/__init__.py +41 -0
  18. code_constraints/core/annotations.py +217 -0
  19. code_constraints/core/associations.py +134 -0
  20. code_constraints/core/diff.py +302 -0
  21. code_constraints/core/editor_io.py +280 -0
  22. code_constraints/core/graph_model.py +681 -0
  23. code_constraints/core/keys.py +105 -0
  24. code_constraints/core/model.py +294 -0
  25. code_constraints/core/model_io.py +65 -0
  26. code_constraints/core/receivers.py +34 -0
  27. code_constraints/core/rules.py +177 -0
  28. code_constraints/core/rulesdoc.py +208 -0
  29. code_constraints/core/tags.py +114 -0
  30. code_constraints/core/ts_fingerprint.py +88 -0
  31. code_constraints/core/xmi_reader.py +358 -0
  32. code_constraints/core/xmi_writer.py +373 -0
  33. code_constraints/csharp/__init__.py +3 -0
  34. code_constraints/csharp/activity.py +250 -0
  35. code_constraints/csharp/conformance.py +331 -0
  36. code_constraints/csharp/fingerprint.py +274 -0
  37. code_constraints/csharp/parser.py +436 -0
  38. code_constraints/csharp/rules_extract.py +78 -0
  39. code_constraints/csharp/sequence.py +295 -0
  40. code_constraints/enforce/__init__.py +15 -0
  41. code_constraints/enforce/engine.py +122 -0
  42. code_constraints/enforce/model.py +74 -0
  43. code_constraints/julia/__init__.py +5 -0
  44. code_constraints/julia/conformance.py +282 -0
  45. code_constraints/julia/fingerprint.py +226 -0
  46. code_constraints/julia/parser.py +523 -0
  47. code_constraints/julia/rules_extract.py +216 -0
  48. code_constraints/lint/__init__.py +10 -0
  49. code_constraints/lint/baseline.py +96 -0
  50. code_constraints/lint/config.py +239 -0
  51. code_constraints/lint/engine.py +179 -0
  52. code_constraints/lint/pipeline.py +108 -0
  53. code_constraints/lint/report.py +151 -0
  54. code_constraints/lint/rules/__init__.py +50 -0
  55. code_constraints/lint/rules/base.py +200 -0
  56. code_constraints/lint/rules/cyclic_package_dependencies.py +69 -0
  57. code_constraints/lint/rules/dangling_classes.py +98 -0
  58. code_constraints/lint/rules/forbidden_package_references.py +47 -0
  59. code_constraints/lint/rules/forbidden_references.py +48 -0
  60. code_constraints/lint/rules/frozen_members.py +67 -0
  61. code_constraints/lint/rules/frozen_rules.py +105 -0
  62. code_constraints/lint/rules/implementation_locks.py +156 -0
  63. code_constraints/lint/rules/layer_dependencies.py +92 -0
  64. code_constraints/lint/rules/max_class_fanout.py +41 -0
  65. code_constraints/lint/rules/no_new_classes.py +27 -0
  66. code_constraints/lint/rules/no_removed_classes.py +27 -0
  67. code_constraints/lint/rules/reference_architecture.py +111 -0
  68. code_constraints/lint/rules/subclass_naming.py +71 -0
  69. code_constraints/lint/rules/tag_conformance.py +76 -0
  70. code_constraints/lock/__init__.py +73 -0
  71. code_constraints/lock/engine.py +395 -0
  72. code_constraints/lock/model.py +235 -0
  73. code_constraints/lock/store.py +144 -0
  74. code_constraints/lua/__init__.py +5 -0
  75. code_constraints/lua/conformance.py +239 -0
  76. code_constraints/lua/fingerprint.py +252 -0
  77. code_constraints/lua/parser.py +500 -0
  78. code_constraints/lua/rules_extract.py +55 -0
  79. code_constraints/mcp/__init__.py +20 -0
  80. code_constraints/mcp/__main__.py +73 -0
  81. code_constraints/mcp/server.py +1203 -0
  82. code_constraints/odin/__init__.py +5 -0
  83. code_constraints/odin/conformance.py +244 -0
  84. code_constraints/odin/fingerprint.py +159 -0
  85. code_constraints/odin/parser.py +471 -0
  86. code_constraints/odin/rules_extract.py +38 -0
  87. code_constraints/python/__init__.py +3 -0
  88. code_constraints/python/activity.py +278 -0
  89. code_constraints/python/conformance.py +249 -0
  90. code_constraints/python/fingerprint.py +231 -0
  91. code_constraints/python/parser.py +330 -0
  92. code_constraints/python/rules_extract.py +83 -0
  93. code_constraints/python/sequence.py +257 -0
  94. code_constraints/reference/__init__.py +15 -0
  95. code_constraints/reference/compare.py +356 -0
  96. code_constraints/reference/report.py +38 -0
  97. code_constraints/svelte/__init__.py +3 -0
  98. code_constraints/svelte/parser.py +523 -0
  99. code_constraints/typescript/__init__.py +3 -0
  100. code_constraints/typescript/parser.py +590 -0
  101. code_constraints/waivers/__init__.py +89 -0
  102. code_constraints/waivers/collect.py +167 -0
  103. code_constraints/waivers/model.py +90 -0
  104. code_constraints/waivers/ops.py +150 -0
  105. code_constraints/waivers/review.py +156 -0
  106. code_constraints/waivers/store.py +300 -0
  107. code_constraints/web/__init__.py +0 -0
  108. code_constraints/web/_static/assets/index-3ivBsYY4.css +1 -0
  109. code_constraints/web/_static/assets/index-BTzTqGFp.js +9 -0
  110. code_constraints/web/_static/index.html +13 -0
  111. code_constraints/web/app.py +1076 -0
  112. code_constraints-0.1.0.dist-info/METADATA +663 -0
  113. code_constraints-0.1.0.dist-info/RECORD +116 -0
  114. code_constraints-0.1.0.dist-info/WHEEL +4 -0
  115. code_constraints-0.1.0.dist-info/entry_points.txt +3 -0
  116. code_constraints-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )