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,231 @@
1
+ """AST fingerprinting for Python (`cdec lock`, Engine C).
2
+
3
+ Produces a digest per lockable element (module-level function, class, method,
4
+ nested class) that captures *semantic* content only:
5
+
6
+ * line/column attributes are never serialised, so inserting code above a
7
+ locked function leaves its digest untouched — the whole point of hashing the
8
+ tree rather than a slice of the file;
9
+ * comments aren't in the Python AST at all, and docstrings are stripped
10
+ unless `include_docstrings=True`;
11
+ * the `@locked` tag itself is stripped recursively, so adding or removing a
12
+ lock (or a nested one) never invalidates an enclosing digest.
13
+
14
+ The canonical serialisation is hand-rolled rather than `ast.dump` so it stays
15
+ stable across CPython releases: fields that are `None` or empty are omitted, so
16
+ a newly-introduced optional AST field (e.g. `type_params` in 3.12) doesn't
17
+ silently change every digest. Any genuinely breaking change to this function
18
+ must bump `DIGEST_ALGO`, which surfaces as an `algo-mismatch` violation telling
19
+ the user to re-baseline rather than as a false "implementation changed".
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ import copy
26
+ from hashlib import sha256
27
+ from pathlib import Path
28
+
29
+ from code_constraints.lock.model import LOCK_RULE, LockTarget
30
+ from code_constraints.python.rules_extract import ImportMap, build_import_map, extract_rules
31
+
32
+ DIGEST_ALGO = "py-ast/1"
33
+
34
+ _SKIP_DIR_NAMES = {"__pycache__", ".venv", "venv", ".tox", "build", "dist", ".git"}
35
+ # `type_comment` carries a `# type:` comment; it is documentation, not code.
36
+ _SKIP_FIELDS = {"type_comment"}
37
+
38
+ _FUNC_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef)
39
+
40
+
41
+ def collect_lockables(
42
+ root: str | Path, *, include_docstrings: bool = False
43
+ ) -> list[LockTarget]:
44
+ """Walk `root` and return every lockable element with its digest.
45
+
46
+ Every element is returned, not just tagged ones: the engine needs digests
47
+ for untagged elements too, so it can tell "someone deleted the @locked tag"
48
+ apart from "the element is gone", and so `lock.targets:` globs can freeze
49
+ code that isn't practical to decorate (e.g. a whole test package).
50
+ """
51
+ root_path = Path(root).resolve()
52
+ out: list[LockTarget] = []
53
+ for py_file in sorted(root_path.rglob("*.py")):
54
+ if any(part in _SKIP_DIR_NAMES for part in py_file.parts):
55
+ continue
56
+ try:
57
+ source = py_file.read_text(encoding="utf-8")
58
+ tree = ast.parse(source)
59
+ except (OSError, SyntaxError, ValueError):
60
+ continue
61
+ rel = py_file.relative_to(root_path)
62
+ import_map = build_import_map(tree)
63
+ class_prefix, func_prefix = _scope_prefixes(rel)
64
+ _collect_scope(
65
+ tree.body,
66
+ class_prefix=class_prefix,
67
+ func_prefix=func_prefix,
68
+ in_class=False,
69
+ file=rel.as_posix(),
70
+ import_map=import_map,
71
+ include_docstrings=include_docstrings,
72
+ out=out,
73
+ )
74
+ return out
75
+
76
+
77
+ def _scope_prefixes(rel: Path) -> tuple[str, str]:
78
+ """Return (class_prefix, function_prefix) for a module.
79
+
80
+ Classes use the *package* qualified name so lock targets line up with the
81
+ qualified names everywhere else in the harness (`orders.Receipt`). Module
82
+ functions additionally carry the module stem (`orders.billing.compute_tax`),
83
+ since two modules in one package may define same-named functions.
84
+ """
85
+ pkg = ".".join(rel.parts[:-1])
86
+ stem = rel.stem
87
+ if stem == "__init__":
88
+ module = pkg
89
+ else:
90
+ module = f"{pkg}.{stem}" if pkg else stem
91
+ return pkg, module
92
+
93
+
94
+ def _collect_scope(
95
+ body: list[ast.stmt],
96
+ *,
97
+ class_prefix: str,
98
+ func_prefix: str,
99
+ in_class: bool,
100
+ file: str,
101
+ import_map: ImportMap,
102
+ include_docstrings: bool,
103
+ out: list[LockTarget],
104
+ ) -> None:
105
+ """Group same-named siblings, digest each group, then recurse into classes.
106
+
107
+ Grouping is what makes overloads (`@overload`, `@property` + `@x.setter`)
108
+ behave: one target covers every sibling of that name, so adding, removing,
109
+ or editing any of them moves the group digest.
110
+ """
111
+ groups: dict[str, list[ast.stmt]] = {}
112
+ for node in body:
113
+ if isinstance(node, (ast.ClassDef, *_FUNC_TYPES)):
114
+ groups.setdefault(node.name, []).append(node)
115
+
116
+ for name, nodes in groups.items():
117
+ is_class = isinstance(nodes[0], ast.ClassDef)
118
+ if is_class:
119
+ target = f"{class_prefix}.{name}" if class_prefix else name
120
+ kind = "class"
121
+ else:
122
+ prefix = class_prefix if in_class else func_prefix
123
+ target = f"{prefix}.{name}" if prefix else name
124
+ kind = "method" if in_class else "function"
125
+
126
+ digests = sorted(
127
+ _digest_node(n, import_map, include_docstrings) for n in nodes
128
+ )
129
+ digest = (
130
+ digests[0]
131
+ if len(digests) == 1
132
+ else sha256(("group:" + "|".join(digests)).encode("utf-8")).hexdigest()
133
+ )
134
+
135
+ declared = False
136
+ params: dict[str, str] = {}
137
+ for n in nodes:
138
+ for rule in extract_rules(n.decorator_list, import_map):
139
+ if rule.name == LOCK_RULE:
140
+ declared = True
141
+ params = {**rule.kwargs, **params} if params else dict(rule.kwargs)
142
+
143
+ out.append(
144
+ LockTarget(
145
+ target=target,
146
+ kind=kind, # type: ignore[arg-type]
147
+ digest=digest,
148
+ algo=DIGEST_ALGO,
149
+ file=file,
150
+ line=getattr(nodes[0], "lineno", 0),
151
+ declared=declared,
152
+ params=params,
153
+ )
154
+ )
155
+
156
+ if is_class:
157
+ for n in nodes:
158
+ assert isinstance(n, ast.ClassDef)
159
+ _collect_scope(
160
+ n.body,
161
+ class_prefix=target,
162
+ func_prefix=target,
163
+ in_class=True,
164
+ file=file,
165
+ import_map=import_map,
166
+ include_docstrings=include_docstrings,
167
+ out=out,
168
+ )
169
+
170
+
171
+ # ---------- digest ----------
172
+
173
+ def _digest_node(
174
+ node: ast.stmt, import_map: ImportMap, include_docstrings: bool
175
+ ) -> str:
176
+ clone = copy.deepcopy(node)
177
+ _normalize(clone, import_map, include_docstrings)
178
+ return sha256(_canonical(clone).encode("utf-8")).hexdigest()
179
+
180
+
181
+ def _normalize(node: ast.AST, import_map: ImportMap, include_docstrings: bool) -> None:
182
+ """Strip lock tags and (optionally) docstrings from the whole subtree."""
183
+ for sub in ast.walk(node):
184
+ decorators = getattr(sub, "decorator_list", None)
185
+ if decorators is not None:
186
+ sub.decorator_list = [ # type: ignore[attr-defined]
187
+ d for d in decorators if not _is_lock_decorator(d, import_map)
188
+ ]
189
+ if not include_docstrings and isinstance(
190
+ sub, (ast.Module, ast.ClassDef, *_FUNC_TYPES)
191
+ ):
192
+ _strip_docstring(sub)
193
+
194
+
195
+ def _is_lock_decorator(dec: ast.expr, import_map: ImportMap) -> bool:
196
+ return any(r.name == LOCK_RULE for r in extract_rules([dec], import_map))
197
+
198
+
199
+ def _strip_docstring(node: ast.AST) -> None:
200
+ body = getattr(node, "body", None)
201
+ if not body or not isinstance(body, list):
202
+ return
203
+ first = body[0]
204
+ if (
205
+ isinstance(first, ast.Expr)
206
+ and isinstance(first.value, ast.Constant)
207
+ and isinstance(first.value.value, str)
208
+ ):
209
+ # A body must not become empty — `def f(): """doc"""` is legal Python.
210
+ node.body = body[1:] if len(body) > 1 else [ast.Pass()] # type: ignore[attr-defined]
211
+
212
+
213
+ def _canonical(node: object) -> str:
214
+ """Serialise an AST to a deterministic string.
215
+
216
+ Position attributes are excluded (they live in `_attributes`, not
217
+ `_fields`). Empty and `None` fields are omitted so that AST fields added by
218
+ future Python versions don't shift existing digests.
219
+ """
220
+ if isinstance(node, ast.AST):
221
+ parts: list[str] = []
222
+ for name, value in ast.iter_fields(node):
223
+ if name in _SKIP_FIELDS or value is None:
224
+ continue
225
+ if isinstance(value, list) and not value:
226
+ continue
227
+ parts.append(f"{name}={_canonical(value)}")
228
+ return f"{type(node).__name__}({','.join(parts)})"
229
+ if isinstance(node, list):
230
+ return "[" + ",".join(_canonical(v) for v in node) + "]"
231
+ return repr(node)
@@ -0,0 +1,330 @@
1
+ """Parse a Python project (directory of .py files) into a `code_constraints.core.Project`.
2
+
3
+ Strategy:
4
+ - Walk the directory; every `.py` file is parsed with `ast.parse`.
5
+ - Packages are derived from directory structure relative to the root.
6
+ - Each `ClassDef` becomes a `code_constraints.core.Class`. Methods become `Operation`s.
7
+ Class-level type-annotated assignments and `self.x = ...` in `__init__`
8
+ become `Attribute`s.
9
+ - Activity / sequence tags found in source are forwarded to `activity.py` and
10
+ `sequence.py` (built later) which turn tagged regions into UML elements.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import re
17
+ from pathlib import Path
18
+
19
+ from code_constraints.core.model import (
20
+ Attribute,
21
+ Class,
22
+ Operation,
23
+ Package,
24
+ Parameter,
25
+ Project,
26
+ SourceLocation,
27
+ Visibility,
28
+ )
29
+ from code_constraints.core.tags import TagInstance, find_tags
30
+ from code_constraints.python.activity import build_activity_from_tag
31
+ from code_constraints.python.rules_extract import ImportMap, build_import_map, extract_rules
32
+ from code_constraints.python.sequence import build_sequence_from_tag
33
+
34
+
35
+ def parse_project(root: str | Path) -> Project:
36
+ root_path = Path(root).resolve()
37
+ if not root_path.is_dir():
38
+ raise ValueError(f"not a directory: {root_path}")
39
+
40
+ project = Project(source_language="python", root_path=str(root_path))
41
+ package_index: dict[str, Package] = {}
42
+
43
+ for py_file in sorted(root_path.rglob("*.py")):
44
+ if _should_skip(py_file):
45
+ continue
46
+ _parse_file(py_file, root_path, project, package_index)
47
+
48
+ return project
49
+
50
+
51
+ _SKIP_DIR_NAMES = {"__pycache__", ".venv", "venv", ".tox", "build", "dist", ".git"}
52
+
53
+
54
+ def _should_skip(p: Path) -> bool:
55
+ return any(part in _SKIP_DIR_NAMES for part in p.parts)
56
+
57
+
58
+ def _parse_file(
59
+ file: Path,
60
+ root: Path,
61
+ project: Project,
62
+ package_index: dict[str, Package],
63
+ ) -> None:
64
+ rel = file.relative_to(root)
65
+ package_qn = _qualified_package_name(rel)
66
+ pkg = _ensure_package(project, package_qn, package_index)
67
+
68
+ source = file.read_text(encoding="utf-8", errors="replace")
69
+ try:
70
+ tree = ast.parse(source, filename=str(file))
71
+ except SyntaxError:
72
+ return
73
+
74
+ file_str = str(rel.as_posix())
75
+ import_map = build_import_map(tree)
76
+
77
+ for node in tree.body:
78
+ if isinstance(node, ast.ClassDef):
79
+ cls = _class_from_node(node, package_qn, file_str, import_map)
80
+ pkg.classes.append(cls)
81
+
82
+ tags = find_tags(source, comment_prefix="#")
83
+ for tag in tags:
84
+ if tag.kind == "uml-activity" and not tag.self_closed:
85
+ act = build_activity_from_tag(tag, tree, source, file=file_str)
86
+ if act is not None:
87
+ project.activities.append(act)
88
+ elif tag.kind == "uml-sequence" and not tag.self_closed:
89
+ seq = build_sequence_from_tag(tag, tree, source, file=file_str)
90
+ if seq is not None:
91
+ project.sequences.append(seq)
92
+
93
+
94
+ def _qualified_package_name(rel_file: Path) -> str:
95
+ parts = list(rel_file.parts[:-1]) # drop the file itself
96
+ if not parts:
97
+ return "__root__"
98
+ return ".".join(parts)
99
+
100
+
101
+ def _ensure_package(
102
+ project: Project, qualified_name: str, index: dict[str, Package]
103
+ ) -> Package:
104
+ if qualified_name in index:
105
+ return index[qualified_name]
106
+
107
+ if qualified_name == "__root__":
108
+ pkg = Package(name="__root__", qualified_name="__root__")
109
+ project.packages.append(pkg)
110
+ index[qualified_name] = pkg
111
+ return pkg
112
+
113
+ parts = qualified_name.split(".")
114
+ parent_qn = ".".join(parts[:-1]) if len(parts) > 1 else None
115
+ if parent_qn is None:
116
+ pkg = Package(name=parts[0], qualified_name=qualified_name)
117
+ project.packages.append(pkg)
118
+ else:
119
+ parent = _ensure_package(project, parent_qn, index)
120
+ pkg = Package(name=parts[-1], qualified_name=qualified_name)
121
+ parent.sub_packages.append(pkg)
122
+ index[qualified_name] = pkg
123
+ return pkg
124
+
125
+
126
+ # ---------- class / member extraction ----------
127
+
128
+ def _class_from_node(
129
+ node: ast.ClassDef, package_qn: str, file: str, import_map: ImportMap
130
+ ) -> Class:
131
+ qn = f"{package_qn}.{node.name}" if package_qn and package_qn != "__root__" else node.name
132
+ cls = Class(
133
+ name=node.name,
134
+ qualified_name=qn,
135
+ kind=_class_kind(node),
136
+ bases=[_unparse(b) for b in node.bases],
137
+ location=SourceLocation(
138
+ file=file,
139
+ start_line=getattr(node, "lineno", 0),
140
+ end_line=getattr(node, "end_lineno", 0) or getattr(node, "lineno", 0),
141
+ ),
142
+ description=ast.get_docstring(node, clean=True) or None,
143
+ rules=extract_rules(node.decorator_list, import_map),
144
+ )
145
+
146
+ for body_node in node.body:
147
+ if isinstance(body_node, ast.AnnAssign) and isinstance(body_node.target, ast.Name):
148
+ cls.attributes.append(
149
+ Attribute(
150
+ name=body_node.target.id,
151
+ type=_unparse(body_node.annotation) if body_node.annotation else "",
152
+ visibility=_visibility(body_node.target.id),
153
+ is_static=True,
154
+ default=_unparse(body_node.value) if body_node.value else None,
155
+ )
156
+ )
157
+ elif isinstance(body_node, ast.Assign):
158
+ for tgt in body_node.targets:
159
+ if isinstance(tgt, ast.Name):
160
+ cls.attributes.append(
161
+ Attribute(
162
+ name=tgt.id,
163
+ type="",
164
+ visibility=_visibility(tgt.id),
165
+ is_static=True,
166
+ default=_unparse(body_node.value) if body_node.value else None,
167
+ )
168
+ )
169
+ elif isinstance(body_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
170
+ cls.operations.append(_operation_from_func(body_node, import_map))
171
+ cls.dependencies.extend(_body_type_refs(body_node))
172
+ if body_node.name == "__init__":
173
+ for attr in _instance_attributes_from_init(body_node):
174
+ if not any(a.name == attr.name for a in cls.attributes):
175
+ cls.attributes.append(attr)
176
+
177
+ cls.dependencies = _dedupe_refs(cls.dependencies, drop=node.name)
178
+ return cls
179
+
180
+
181
+ def _class_kind(node: ast.ClassDef) -> str:
182
+ for base in node.bases:
183
+ base_name = _unparse(base)
184
+ if base_name in {"ABC", "abc.ABC"}:
185
+ return "abstract"
186
+ if base_name in {"Enum", "IntEnum", "enum.Enum", "enum.IntEnum"}:
187
+ return "enum"
188
+ if base_name in {"Protocol", "typing.Protocol"}:
189
+ return "interface"
190
+ for kw in node.keywords:
191
+ if kw.arg == "metaclass" and _unparse(kw.value) in {"ABCMeta", "abc.ABCMeta"}:
192
+ return "abstract"
193
+ return "class"
194
+
195
+
196
+ def _operation_from_func(
197
+ node: ast.FunctionDef | ast.AsyncFunctionDef, import_map: ImportMap
198
+ ) -> Operation:
199
+ params: list[Parameter] = []
200
+ args = node.args
201
+ pos = list(args.posonlyargs) + list(args.args)
202
+ defaults = list(args.defaults)
203
+ default_offset = len(pos) - len(defaults)
204
+ for i, arg in enumerate(pos):
205
+ if arg.arg == "self":
206
+ continue
207
+ default = (
208
+ _unparse(defaults[i - default_offset]) if i >= default_offset else None
209
+ )
210
+ params.append(
211
+ Parameter(
212
+ name=arg.arg,
213
+ type=_unparse(arg.annotation) if arg.annotation else "",
214
+ default=default,
215
+ )
216
+ )
217
+ for arg, default in zip(args.kwonlyargs, args.kw_defaults):
218
+ params.append(
219
+ Parameter(
220
+ name=arg.arg,
221
+ type=_unparse(arg.annotation) if arg.annotation else "",
222
+ default=_unparse(default) if default else None,
223
+ )
224
+ )
225
+
226
+ is_static = any(_is_decorator(d, "staticmethod") for d in node.decorator_list)
227
+ is_abstract = any(
228
+ _is_decorator(d, "abstractmethod") or _is_decorator(d, "abc.abstractmethod")
229
+ for d in node.decorator_list
230
+ )
231
+
232
+ return Operation(
233
+ name=node.name,
234
+ parameters=params,
235
+ return_type=_unparse(node.returns) if node.returns else "",
236
+ visibility=_visibility(node.name),
237
+ is_static=is_static,
238
+ is_abstract=is_abstract,
239
+ description=ast.get_docstring(node, clean=True) or None,
240
+ rules=extract_rules(node.decorator_list, import_map),
241
+ )
242
+
243
+
244
+ def _instance_attributes_from_init(
245
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
246
+ ) -> list[Attribute]:
247
+ out: list[Attribute] = []
248
+ for stmt in ast.walk(node):
249
+ if isinstance(stmt, ast.AnnAssign) and _is_self_attr(stmt.target):
250
+ assert isinstance(stmt.target, ast.Attribute)
251
+ out.append(
252
+ Attribute(
253
+ name=stmt.target.attr,
254
+ type=_unparse(stmt.annotation) if stmt.annotation else "",
255
+ visibility=_visibility(stmt.target.attr),
256
+ )
257
+ )
258
+ elif isinstance(stmt, ast.Assign):
259
+ for tgt in stmt.targets:
260
+ if _is_self_attr(tgt):
261
+ assert isinstance(tgt, ast.Attribute)
262
+ out.append(
263
+ Attribute(
264
+ name=tgt.attr,
265
+ type="",
266
+ visibility=_visibility(tgt.attr),
267
+ )
268
+ )
269
+ return out
270
+
271
+
272
+ def _is_self_attr(node: ast.AST) -> bool:
273
+ return (
274
+ isinstance(node, ast.Attribute)
275
+ and isinstance(node.value, ast.Name)
276
+ and node.value.id == "self"
277
+ )
278
+
279
+
280
+ def _is_decorator(dec: ast.expr, name: str) -> bool:
281
+ return _unparse(dec) == name
282
+
283
+
284
+ def _visibility(name: str) -> Visibility:
285
+ if name.startswith("__") and not name.endswith("__"):
286
+ return Visibility.PRIVATE
287
+ if name.startswith("_"):
288
+ return Visibility.PROTECTED
289
+ return Visibility.PUBLIC
290
+
291
+
292
+ def _unparse(node: ast.AST | None) -> str:
293
+ if node is None:
294
+ return ""
295
+ try:
296
+ return ast.unparse(node)
297
+ except Exception:
298
+ return ""
299
+
300
+
301
+ _PASCAL_IDENT_RE = re.compile(r"^[A-Z][A-Za-z0-9_]*$")
302
+
303
+
304
+ def _body_type_refs(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
305
+ """Collect PascalCase names referenced inside a function body.
306
+
307
+ Mirrors the C# parser heuristic: a PascalCase `Name` (a constructor call
308
+ `Foo()`, a static-call receiver `Foo.bar()`, a local annotation, etc.) is a
309
+ candidate type reference. `resolve_association` later keeps only those that
310
+ map to a project class.
311
+ """
312
+ out: list[str] = []
313
+ seen: set[str] = set()
314
+ for n in ast.walk(node):
315
+ if isinstance(n, ast.Name) and n.id not in seen and _PASCAL_IDENT_RE.match(n.id):
316
+ seen.add(n.id)
317
+ out.append(n.id)
318
+ return out
319
+
320
+
321
+ def _dedupe_refs(refs: list[str], *, drop: str = "") -> list[str]:
322
+ """Order-preserving dedupe, dropping a given name (the owning class)."""
323
+ out: list[str] = []
324
+ seen: set[str] = set()
325
+ for r in refs:
326
+ if r == drop or r in seen:
327
+ continue
328
+ seen.add(r)
329
+ out.append(r)
330
+ return out
@@ -0,0 +1,83 @@
1
+ """Recognise architectural-rule decorators on Python classes/functions.
2
+
3
+ Shared by the UML parser (to attach `RuleAnnotation`s to the model) and the
4
+ `cdec enforce` conformance analyzer (to find tagged elements). A decorator counts
5
+ as a rule only when its base name was imported from one of the shim modules
6
+ (`cdec_rules` / `code_constraints.rules`) — so a user's own decorator that happens to
7
+ share a name is never misread as a rule.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ from dataclasses import dataclass, field
14
+
15
+ from code_constraints.core.model import RuleAnnotation
16
+ from code_constraints.core.rules import PYTHON_SHIM_MODULES, by_python_name
17
+
18
+
19
+ @dataclass
20
+ class ImportMap:
21
+ # local name -> exported shim name, for `from <shim> import x [as y]`
22
+ from_imports: dict[str, str] = field(default_factory=dict)
23
+ # local module alias -> real shim module dotted name
24
+ module_aliases: dict[str, str] = field(default_factory=dict)
25
+
26
+
27
+ def build_import_map(tree: ast.AST) -> ImportMap:
28
+ im = ImportMap(module_aliases={m: m for m in PYTHON_SHIM_MODULES})
29
+ for node in ast.walk(tree):
30
+ if isinstance(node, ast.ImportFrom):
31
+ mod = node.module or ""
32
+ if mod in PYTHON_SHIM_MODULES:
33
+ for alias in node.names:
34
+ im.from_imports[alias.asname or alias.name] = alias.name
35
+ elif mod == "code_constraints":
36
+ for alias in node.names:
37
+ if alias.name == "rules":
38
+ im.module_aliases[alias.asname or "rules"] = "code_constraints.rules"
39
+ elif isinstance(node, ast.Import):
40
+ for alias in node.names:
41
+ if alias.name in PYTHON_SHIM_MODULES and alias.asname:
42
+ im.module_aliases[alias.asname] = alias.name
43
+ return im
44
+
45
+
46
+ def extract_rules(decorator_list: list[ast.expr], import_map: ImportMap) -> list[RuleAnnotation]:
47
+ rules: list[RuleAnnotation] = []
48
+ for dec in decorator_list:
49
+ callee = dec.func if isinstance(dec, ast.Call) else dec
50
+ spec_id = _resolve(callee, import_map)
51
+ if spec_id is None:
52
+ continue
53
+ args: list[str] = []
54
+ kwargs: dict[str, str] = {}
55
+ if isinstance(dec, ast.Call):
56
+ args = [_unparse(a) for a in dec.args]
57
+ for kw in dec.keywords:
58
+ if kw.arg is not None:
59
+ kwargs[kw.arg] = _unparse(kw.value)
60
+ rules.append(RuleAnnotation(name=spec_id, args=args, kwargs=kwargs))
61
+ return rules
62
+
63
+
64
+ def _resolve(callee: ast.expr, im: ImportMap) -> str | None:
65
+ text = _unparse(callee)
66
+ if not text:
67
+ return None
68
+ if "." not in text:
69
+ exported = im.from_imports.get(text)
70
+ spec = by_python_name(exported) if exported else None
71
+ else:
72
+ prefix, _, final = text.rpartition(".")
73
+ spec = by_python_name(final) if prefix in im.module_aliases else None
74
+ return spec.id if spec else None
75
+
76
+
77
+ def _unparse(node: ast.AST | None) -> str:
78
+ if node is None:
79
+ return ""
80
+ try:
81
+ return ast.unparse(node)
82
+ except Exception:
83
+ return ""