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