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,278 @@
|
|
|
1
|
+
"""Translate the AST inside a tagged region into a UML `Activity`.
|
|
2
|
+
|
|
3
|
+
Three granularities are supported (chosen via the tag's `granularity` attribute):
|
|
4
|
+
- `control-flow` (default): `if/while/for/try` become decision/merge nodes;
|
|
5
|
+
other statements collapse into action nodes.
|
|
6
|
+
- `statement`: every statement becomes an action node, linked sequentially.
|
|
7
|
+
- `calls`: only `Call` expressions inside the tagged region become action nodes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
from typing import Iterable
|
|
14
|
+
|
|
15
|
+
from code_constraints.core.model import (
|
|
16
|
+
Activity,
|
|
17
|
+
ActivityEdge,
|
|
18
|
+
ActivityNode,
|
|
19
|
+
SourceLocation,
|
|
20
|
+
)
|
|
21
|
+
from code_constraints.core.tags import TagInstance
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_activity_from_tag(
|
|
25
|
+
tag: TagInstance, tree: ast.Module, source: str, *, file: str
|
|
26
|
+
) -> Activity | None:
|
|
27
|
+
if not tag.name:
|
|
28
|
+
return None
|
|
29
|
+
granularity = tag.attributes.get("granularity", "control-flow")
|
|
30
|
+
if granularity not in {"control-flow", "statement", "calls"}:
|
|
31
|
+
granularity = "control-flow"
|
|
32
|
+
|
|
33
|
+
body = _statements_in_range(tree, tag.start_line, tag.end_line)
|
|
34
|
+
if not body:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
builder = _ActivityBuilder()
|
|
38
|
+
builder.start()
|
|
39
|
+
if granularity == "statement":
|
|
40
|
+
builder.linear(body, label_fn=_statement_label)
|
|
41
|
+
elif granularity == "calls":
|
|
42
|
+
calls = [c for stmt in body for c in _walk_calls(stmt)]
|
|
43
|
+
builder.linear(calls, label_fn=_call_label)
|
|
44
|
+
else:
|
|
45
|
+
builder.control_flow(body)
|
|
46
|
+
builder.finish()
|
|
47
|
+
|
|
48
|
+
return Activity(
|
|
49
|
+
name=tag.name,
|
|
50
|
+
nodes=builder.nodes,
|
|
51
|
+
edges=builder.edges,
|
|
52
|
+
granularity=granularity, # type: ignore[arg-type]
|
|
53
|
+
location=SourceLocation(file=file, start_line=tag.start_line, end_line=tag.end_line),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _statements_in_range(tree: ast.Module, start: int, end: int) -> list[ast.stmt]:
|
|
58
|
+
"""Return top-level-ish statements whose source range overlaps [start, end]
|
|
59
|
+
and whose first line is strictly inside the tag span (so the tag comment
|
|
60
|
+
lines themselves don't get included)."""
|
|
61
|
+
out: list[ast.stmt] = []
|
|
62
|
+
# Prefer the innermost function whose body contains the tag region.
|
|
63
|
+
candidates: list[tuple[int, list[ast.stmt]]] = []
|
|
64
|
+
for node in ast.walk(tree):
|
|
65
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
66
|
+
f_start = getattr(node, "lineno", 0)
|
|
67
|
+
# Use the first body statement to know where the body starts,
|
|
68
|
+
# and the last body statement's end_lineno for where it ends.
|
|
69
|
+
if not node.body:
|
|
70
|
+
continue
|
|
71
|
+
body_start = getattr(node.body[0], "lineno", f_start)
|
|
72
|
+
body_end = max(
|
|
73
|
+
(getattr(s, "end_lineno", getattr(s, "lineno", 0)) or 0)
|
|
74
|
+
for s in node.body
|
|
75
|
+
)
|
|
76
|
+
# Function envelopes the tag if its def line is before the tag
|
|
77
|
+
# start and any of its body extends to or past the tag start.
|
|
78
|
+
if f_start < start and body_end >= start:
|
|
79
|
+
stmts = [
|
|
80
|
+
s for s in node.body if start < getattr(s, "lineno", 0) < end
|
|
81
|
+
]
|
|
82
|
+
if stmts:
|
|
83
|
+
candidates.append((f_start, stmts))
|
|
84
|
+
if candidates:
|
|
85
|
+
# Innermost = the one whose body_start is latest among containers.
|
|
86
|
+
candidates.sort(key=lambda c: c[0])
|
|
87
|
+
return candidates[-1][1]
|
|
88
|
+
# Otherwise look at module-level statements in range.
|
|
89
|
+
for stmt in tree.body:
|
|
90
|
+
s_start = getattr(stmt, "lineno", 0)
|
|
91
|
+
if start < s_start < end:
|
|
92
|
+
out.append(stmt)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _ActivityBuilder:
|
|
97
|
+
def __init__(self) -> None:
|
|
98
|
+
self.nodes: list[ActivityNode] = []
|
|
99
|
+
self.edges: list[ActivityEdge] = []
|
|
100
|
+
self._counter = 0
|
|
101
|
+
self._last_id: str | None = None
|
|
102
|
+
|
|
103
|
+
def _new_id(self, prefix: str) -> str:
|
|
104
|
+
self._counter += 1
|
|
105
|
+
return f"{prefix}{self._counter}"
|
|
106
|
+
|
|
107
|
+
def _add(self, node: ActivityNode) -> str:
|
|
108
|
+
self.nodes.append(node)
|
|
109
|
+
if self._last_id is not None:
|
|
110
|
+
self.edges.append(ActivityEdge(source=self._last_id, target=node.id))
|
|
111
|
+
self._last_id = node.id
|
|
112
|
+
return node.id
|
|
113
|
+
|
|
114
|
+
def start(self) -> None:
|
|
115
|
+
self._add(ActivityNode(id=self._new_id("init"), kind="initial"))
|
|
116
|
+
|
|
117
|
+
def finish(self) -> None:
|
|
118
|
+
self._add(ActivityNode(id=self._new_id("final"), kind="final"))
|
|
119
|
+
|
|
120
|
+
def linear(self, items: Iterable, *, label_fn) -> None:
|
|
121
|
+
for item in items:
|
|
122
|
+
self._add(
|
|
123
|
+
ActivityNode(
|
|
124
|
+
id=self._new_id("a"),
|
|
125
|
+
kind="action",
|
|
126
|
+
label=label_fn(item),
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def control_flow(self, stmts: list[ast.stmt]) -> None:
|
|
131
|
+
for stmt in stmts:
|
|
132
|
+
self._handle_stmt(stmt)
|
|
133
|
+
|
|
134
|
+
def _handle_stmt(self, stmt: ast.stmt) -> None:
|
|
135
|
+
if isinstance(stmt, ast.If):
|
|
136
|
+
self._handle_if(stmt)
|
|
137
|
+
elif isinstance(stmt, (ast.For, ast.AsyncFor, ast.While)):
|
|
138
|
+
self._handle_loop(stmt)
|
|
139
|
+
elif isinstance(stmt, ast.Try):
|
|
140
|
+
self._handle_try(stmt)
|
|
141
|
+
elif isinstance(stmt, ast.Return):
|
|
142
|
+
self._add(
|
|
143
|
+
ActivityNode(
|
|
144
|
+
id=self._new_id("a"),
|
|
145
|
+
kind="action",
|
|
146
|
+
label=f"return {_unparse(stmt.value)}".strip(),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
elif isinstance(stmt, ast.Raise):
|
|
150
|
+
self._add(
|
|
151
|
+
ActivityNode(
|
|
152
|
+
id=self._new_id("a"),
|
|
153
|
+
kind="action",
|
|
154
|
+
label=f"raise {_unparse(stmt.exc)}".strip(),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
self._add(
|
|
159
|
+
ActivityNode(
|
|
160
|
+
id=self._new_id("a"),
|
|
161
|
+
kind="action",
|
|
162
|
+
label=_statement_label(stmt),
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _handle_if(self, stmt: ast.If) -> None:
|
|
167
|
+
decision_id = self._new_id("d")
|
|
168
|
+
decision = ActivityNode(
|
|
169
|
+
id=decision_id, kind="decision", label=_unparse(stmt.test)
|
|
170
|
+
)
|
|
171
|
+
if self._last_id is not None:
|
|
172
|
+
self.edges.append(ActivityEdge(source=self._last_id, target=decision_id))
|
|
173
|
+
self.nodes.append(decision)
|
|
174
|
+
merge_id = self._new_id("m")
|
|
175
|
+
merge = ActivityNode(id=merge_id, kind="merge")
|
|
176
|
+
|
|
177
|
+
# then branch
|
|
178
|
+
self._last_id = decision_id
|
|
179
|
+
# tag the next edge with guard "yes"
|
|
180
|
+
prev_edges_count = len(self.edges)
|
|
181
|
+
for s in stmt.body:
|
|
182
|
+
self._handle_stmt(s)
|
|
183
|
+
# mark first edge of branch as "yes"
|
|
184
|
+
if len(self.edges) > prev_edges_count:
|
|
185
|
+
self.edges[prev_edges_count].guard = "yes"
|
|
186
|
+
if self._last_id is not None:
|
|
187
|
+
self.edges.append(ActivityEdge(source=self._last_id, target=merge_id))
|
|
188
|
+
|
|
189
|
+
# else branch
|
|
190
|
+
self._last_id = decision_id
|
|
191
|
+
prev_edges_count = len(self.edges)
|
|
192
|
+
for s in stmt.orelse:
|
|
193
|
+
self._handle_stmt(s)
|
|
194
|
+
if not stmt.orelse:
|
|
195
|
+
# empty else: direct edge from decision to merge
|
|
196
|
+
self.edges.append(
|
|
197
|
+
ActivityEdge(source=decision_id, target=merge_id, guard="no")
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
if len(self.edges) > prev_edges_count:
|
|
201
|
+
self.edges[prev_edges_count].guard = "no"
|
|
202
|
+
if self._last_id is not None:
|
|
203
|
+
self.edges.append(ActivityEdge(source=self._last_id, target=merge_id))
|
|
204
|
+
|
|
205
|
+
self.nodes.append(merge)
|
|
206
|
+
self._last_id = merge_id
|
|
207
|
+
|
|
208
|
+
def _handle_loop(self, stmt: ast.AST) -> None:
|
|
209
|
+
if isinstance(stmt, (ast.For, ast.AsyncFor)):
|
|
210
|
+
label = f"for {_unparse(stmt.target)} in {_unparse(stmt.iter)}"
|
|
211
|
+
else:
|
|
212
|
+
assert isinstance(stmt, ast.While)
|
|
213
|
+
label = f"while {_unparse(stmt.test)}"
|
|
214
|
+
|
|
215
|
+
decision_id = self._new_id("d")
|
|
216
|
+
decision = ActivityNode(id=decision_id, kind="decision", label=label)
|
|
217
|
+
if self._last_id is not None:
|
|
218
|
+
self.edges.append(ActivityEdge(source=self._last_id, target=decision_id))
|
|
219
|
+
self.nodes.append(decision)
|
|
220
|
+
|
|
221
|
+
merge_id = self._new_id("m")
|
|
222
|
+
merge = ActivityNode(id=merge_id, kind="merge")
|
|
223
|
+
|
|
224
|
+
# loop body
|
|
225
|
+
self._last_id = decision_id
|
|
226
|
+
prev_edges_count = len(self.edges)
|
|
227
|
+
body = stmt.body if hasattr(stmt, "body") else [] # type: ignore[attr-defined]
|
|
228
|
+
for s in body:
|
|
229
|
+
self._handle_stmt(s)
|
|
230
|
+
if len(self.edges) > prev_edges_count:
|
|
231
|
+
self.edges[prev_edges_count].guard = "loop"
|
|
232
|
+
if self._last_id is not None:
|
|
233
|
+
self.edges.append(ActivityEdge(source=self._last_id, target=decision_id))
|
|
234
|
+
|
|
235
|
+
# exit edge
|
|
236
|
+
self.edges.append(
|
|
237
|
+
ActivityEdge(source=decision_id, target=merge_id, guard="exit")
|
|
238
|
+
)
|
|
239
|
+
self.nodes.append(merge)
|
|
240
|
+
self._last_id = merge_id
|
|
241
|
+
|
|
242
|
+
def _handle_try(self, stmt: ast.Try) -> None:
|
|
243
|
+
# Simplified: a try is a single action followed by branches per handler.
|
|
244
|
+
try_id = self._new_id("a")
|
|
245
|
+
self._add(ActivityNode(id=try_id, kind="action", label="try"))
|
|
246
|
+
for handler in stmt.handlers:
|
|
247
|
+
exc = _unparse(handler.type) if handler.type else "*"
|
|
248
|
+
self._add(
|
|
249
|
+
ActivityNode(
|
|
250
|
+
id=self._new_id("a"),
|
|
251
|
+
kind="action",
|
|
252
|
+
label=f"except {exc}",
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _statement_label(stmt: ast.stmt) -> str:
|
|
258
|
+
text = _unparse(stmt).strip()
|
|
259
|
+
# First non-empty line, truncated
|
|
260
|
+
first = next((line for line in text.splitlines() if line.strip()), text)
|
|
261
|
+
return first[:60] + ("…" if len(first) > 60 else "")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _walk_calls(stmt: ast.stmt) -> list[ast.Call]:
|
|
265
|
+
return [n for n in ast.walk(stmt) if isinstance(n, ast.Call)]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _call_label(call: ast.Call) -> str:
|
|
269
|
+
return _unparse(call)[:60]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _unparse(node: ast.AST | None) -> str:
|
|
273
|
+
if node is None:
|
|
274
|
+
return ""
|
|
275
|
+
try:
|
|
276
|
+
return ast.unparse(node)
|
|
277
|
+
except Exception:
|
|
278
|
+
return ""
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Body-level conformance analysis for Python (`cdec enforce`, Engine B).
|
|
2
|
+
|
|
3
|
+
Re-parses Python source with `ast` and inspects method bodies for violations of
|
|
4
|
+
architectural-rule tags. Reuses the shared recognizer in `rules_extract` so the
|
|
5
|
+
set of "what is a rule" stays identical to the UML parser.
|
|
6
|
+
|
|
7
|
+
Detection is heuristic — there is no type resolution at parse time:
|
|
8
|
+
* `no-instantiation` flags a call whose callee matches a project class or is
|
|
9
|
+
Capitalised (PEP8), unless the type is listed in `allow`.
|
|
10
|
+
* `factory` flags construction of a `creates`-listed type outside its
|
|
11
|
+
designated factory class.
|
|
12
|
+
* `immutable` flags assignment to `self.<field>` outside `__init__`.
|
|
13
|
+
The `allow` kwarg is the documented escape hatch for false positives.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ast
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from code_constraints.core.model import Project
|
|
22
|
+
from code_constraints.enforce.model import Finding
|
|
23
|
+
from code_constraints.python.rules_extract import ImportMap, build_import_map, extract_rules
|
|
24
|
+
|
|
25
|
+
CTOR_RULE = "no-instantiation"
|
|
26
|
+
FACTORY_RULE = "factory"
|
|
27
|
+
IMMUTABLE_RULE = "immutable"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def analyze(root: Path, project: Project) -> list[Finding]:
|
|
31
|
+
qn_by_name = {cls.name: cls.qualified_name for cls in project.iter_classes()}
|
|
32
|
+
project_classes = set(qn_by_name)
|
|
33
|
+
factory_index = _factory_index(project)
|
|
34
|
+
|
|
35
|
+
findings: list[Finding] = []
|
|
36
|
+
for py_file in sorted(root.rglob("*.py")):
|
|
37
|
+
try:
|
|
38
|
+
source = py_file.read_text(encoding="utf-8")
|
|
39
|
+
tree = ast.parse(source)
|
|
40
|
+
except (OSError, SyntaxError):
|
|
41
|
+
continue
|
|
42
|
+
rel = py_file.relative_to(root).as_posix()
|
|
43
|
+
im = build_import_map(tree)
|
|
44
|
+
_analyze_module(
|
|
45
|
+
tree, rel, im, qn_by_name, project_classes, factory_index, findings
|
|
46
|
+
)
|
|
47
|
+
return findings
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _analyze_module(
|
|
51
|
+
tree: ast.Module,
|
|
52
|
+
file: str,
|
|
53
|
+
im: ImportMap,
|
|
54
|
+
qn_by_name: dict[str, str],
|
|
55
|
+
project_classes: set[str],
|
|
56
|
+
factory_index: dict[str, set[str]],
|
|
57
|
+
out: list[Finding],
|
|
58
|
+
) -> None:
|
|
59
|
+
for node in tree.body:
|
|
60
|
+
if isinstance(node, ast.ClassDef):
|
|
61
|
+
_analyze_class(
|
|
62
|
+
node, file, im, qn_by_name, project_classes, factory_index, out
|
|
63
|
+
)
|
|
64
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
65
|
+
frules = _rule_map(node.decorator_list, im)
|
|
66
|
+
_analyze_function(
|
|
67
|
+
node,
|
|
68
|
+
file=file,
|
|
69
|
+
owner_qn=node.name,
|
|
70
|
+
owner_class_name=None,
|
|
71
|
+
noinst_allow=_allow_set(frules.get(CTOR_RULE)) if CTOR_RULE in frules else None,
|
|
72
|
+
immutable=False,
|
|
73
|
+
project_classes=project_classes,
|
|
74
|
+
factory_index=factory_index,
|
|
75
|
+
out=out,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _analyze_class(
|
|
80
|
+
cls: ast.ClassDef,
|
|
81
|
+
file: str,
|
|
82
|
+
im: ImportMap,
|
|
83
|
+
qn_by_name: dict[str, str],
|
|
84
|
+
project_classes: set[str],
|
|
85
|
+
factory_index: dict[str, set[str]],
|
|
86
|
+
out: list[Finding],
|
|
87
|
+
) -> None:
|
|
88
|
+
crules = _rule_map(cls.decorator_list, im)
|
|
89
|
+
class_qn = qn_by_name.get(cls.name, cls.name)
|
|
90
|
+
class_noinst = _allow_set(crules[CTOR_RULE]) if CTOR_RULE in crules else None
|
|
91
|
+
is_immutable = IMMUTABLE_RULE in crules
|
|
92
|
+
|
|
93
|
+
for member in cls.body:
|
|
94
|
+
if not isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
95
|
+
continue
|
|
96
|
+
frules = _rule_map(member.decorator_list, im)
|
|
97
|
+
if CTOR_RULE in frules:
|
|
98
|
+
noinst_allow = _allow_set(frules[CTOR_RULE])
|
|
99
|
+
else:
|
|
100
|
+
noinst_allow = class_noinst
|
|
101
|
+
_analyze_function(
|
|
102
|
+
member,
|
|
103
|
+
file=file,
|
|
104
|
+
owner_qn=class_qn,
|
|
105
|
+
owner_class_name=cls.name,
|
|
106
|
+
noinst_allow=noinst_allow,
|
|
107
|
+
immutable=is_immutable,
|
|
108
|
+
project_classes=project_classes,
|
|
109
|
+
factory_index=factory_index,
|
|
110
|
+
out=out,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _analyze_function(
|
|
115
|
+
func: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
116
|
+
*,
|
|
117
|
+
file: str,
|
|
118
|
+
owner_qn: str,
|
|
119
|
+
owner_class_name: str | None,
|
|
120
|
+
noinst_allow: set[str] | None,
|
|
121
|
+
immutable: bool,
|
|
122
|
+
project_classes: set[str],
|
|
123
|
+
factory_index: dict[str, set[str]],
|
|
124
|
+
out: list[Finding],
|
|
125
|
+
) -> None:
|
|
126
|
+
for callee, line in _constructions(func):
|
|
127
|
+
is_construction = callee in project_classes or (callee[:1].isupper())
|
|
128
|
+
if noinst_allow is not None and is_construction and callee not in noinst_allow:
|
|
129
|
+
out.append(
|
|
130
|
+
Finding(
|
|
131
|
+
rule=CTOR_RULE,
|
|
132
|
+
qualified_name=owner_qn,
|
|
133
|
+
message=(
|
|
134
|
+
f"'{owner_qn}.{func.name}' is tagged @no_instantiation but "
|
|
135
|
+
f"constructs '{callee}'."
|
|
136
|
+
),
|
|
137
|
+
detail=f"{func.name}->{callee}",
|
|
138
|
+
file=file,
|
|
139
|
+
line=line,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
designated = factory_index.get(callee)
|
|
143
|
+
if designated is not None and owner_class_name not in designated:
|
|
144
|
+
allowed = ", ".join(sorted(designated)) or "(none)"
|
|
145
|
+
out.append(
|
|
146
|
+
Finding(
|
|
147
|
+
rule=FACTORY_RULE,
|
|
148
|
+
qualified_name=owner_qn,
|
|
149
|
+
message=(
|
|
150
|
+
f"'{owner_qn}.{func.name}' constructs '{callee}' outside its "
|
|
151
|
+
f"designated factory ({allowed})."
|
|
152
|
+
),
|
|
153
|
+
detail=f"{func.name}->{callee}",
|
|
154
|
+
file=file,
|
|
155
|
+
line=line,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if immutable and func.name != "__init__":
|
|
160
|
+
for field_name, line in _self_assignments(func):
|
|
161
|
+
out.append(
|
|
162
|
+
Finding(
|
|
163
|
+
rule=IMMUTABLE_RULE,
|
|
164
|
+
qualified_name=owner_qn,
|
|
165
|
+
message=(
|
|
166
|
+
f"'{owner_qn}' is @immutable but '{func.name}' reassigns field "
|
|
167
|
+
f"'self.{field_name}' outside __init__."
|
|
168
|
+
),
|
|
169
|
+
detail=f"{func.name}.{field_name}",
|
|
170
|
+
file=file,
|
|
171
|
+
line=line,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _factory_index(project: Project) -> dict[str, set[str]]:
|
|
177
|
+
"""Created-type name -> set of class names designated to construct it."""
|
|
178
|
+
idx: dict[str, set[str]] = {}
|
|
179
|
+
for cls in project.iter_classes():
|
|
180
|
+
rule_sources = list(cls.rules) + [r for op in cls.operations for r in op.rules]
|
|
181
|
+
for rule in rule_sources:
|
|
182
|
+
if rule.name != FACTORY_RULE:
|
|
183
|
+
continue
|
|
184
|
+
for created in _str_list(rule.kwargs.get("creates", "")):
|
|
185
|
+
idx.setdefault(created, set()).add(cls.name)
|
|
186
|
+
return idx
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _rule_map(decorator_list, im: ImportMap) -> dict[str, object]:
|
|
190
|
+
return {r.name: r for r in extract_rules(decorator_list, im)}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _allow_set(rule) -> set[str]:
|
|
194
|
+
if rule is None:
|
|
195
|
+
return set()
|
|
196
|
+
return _str_list(rule.kwargs.get("allow", ""))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _str_list(source_text: str) -> set[str]:
|
|
200
|
+
"""Parse a Python list/tuple literal of strings into a set, e.g.
|
|
201
|
+
"['list', 'dict']" -> {"list", "dict"}. Tolerant: returns {} on anything
|
|
202
|
+
it can't interpret."""
|
|
203
|
+
if not source_text:
|
|
204
|
+
return set()
|
|
205
|
+
try:
|
|
206
|
+
value = ast.literal_eval(source_text)
|
|
207
|
+
except (ValueError, SyntaxError):
|
|
208
|
+
return set()
|
|
209
|
+
if isinstance(value, (list, tuple, set)):
|
|
210
|
+
return {str(v) for v in value}
|
|
211
|
+
return {str(value)}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _constructions(func) -> list[tuple[str, int]]:
|
|
215
|
+
out: list[tuple[str, int]] = []
|
|
216
|
+
for stmt in func.body:
|
|
217
|
+
for node in ast.walk(stmt):
|
|
218
|
+
if isinstance(node, ast.Call):
|
|
219
|
+
name = _callee_name(node.func)
|
|
220
|
+
if name:
|
|
221
|
+
out.append((name, getattr(node, "lineno", 0)))
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _callee_name(func: ast.expr) -> str | None:
|
|
226
|
+
if isinstance(func, ast.Name):
|
|
227
|
+
return func.id
|
|
228
|
+
if isinstance(func, ast.Attribute):
|
|
229
|
+
return func.attr
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _self_assignments(func) -> list[tuple[str, int]]:
|
|
234
|
+
out: list[tuple[str, int]] = []
|
|
235
|
+
for stmt in func.body:
|
|
236
|
+
for node in ast.walk(stmt):
|
|
237
|
+
targets: list[ast.expr] = []
|
|
238
|
+
if isinstance(node, ast.Assign):
|
|
239
|
+
targets = list(node.targets)
|
|
240
|
+
elif isinstance(node, (ast.AugAssign, ast.AnnAssign)):
|
|
241
|
+
targets = [node.target]
|
|
242
|
+
for target in targets:
|
|
243
|
+
if (
|
|
244
|
+
isinstance(target, ast.Attribute)
|
|
245
|
+
and isinstance(target.value, ast.Name)
|
|
246
|
+
and target.value.id == "self"
|
|
247
|
+
):
|
|
248
|
+
out.append((target.attr, getattr(node, "lineno", 0)))
|
|
249
|
+
return out
|