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,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 ""
|