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,5 @@
1
+ """Odin language support: UML parsing, rule recognition, conformance and locks."""
2
+
3
+ from code_constraints.odin.parser import parse_project
4
+
5
+ __all__ = ["parse_project"]
@@ -0,0 +1,244 @@
1
+ """Body-level conformance analysis for Odin (`cdec enforce`, Engine B).
2
+
3
+ Re-parses Odin source with tree-sitter and inspects procedure bodies for
4
+ violations of architectural-rule tags. Reuses `odin.rules_extract` so "what is a
5
+ rule" stays identical to the UML parser, and `core.receivers.resolve_owner` so a
6
+ procedure is attributed to the same struct the model shows.
7
+
8
+ Detection is precise rather than heuristic, because Odin's grammar distinguishes
9
+ the constructs outright:
10
+
11
+ * **Construction** is a composite literal — grammar node type `struct`, as in
12
+ `Money{amount = 1}` — plus `new(T)` / `make(T)` allocations. There is no need
13
+ to guess from a callee's capitalisation the way the Python analyzer must.
14
+ * **Field reassignment** is an `assignment_statement` whose target is a
15
+ `member_expression` rooted at the receiver parameter, so `inv.total = …` is
16
+ caught while an unrelated `other.total = …` is not.
17
+
18
+ Like the other analyzers this only reports; waiver filtering happens in the
19
+ caller.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+
26
+ import tree_sitter_odin
27
+ from tree_sitter import Language, Node, Parser
28
+
29
+ from code_constraints.core.annotations import literal_set
30
+ from code_constraints.core.model import Project, RuleAnnotation
31
+ from code_constraints.core.receivers import resolve_owner
32
+ from code_constraints.enforce.model import Finding
33
+ from code_constraints.odin.parser import (
34
+ _TYPE_DECL_TYPES,
35
+ _package_name,
36
+ _parameters,
37
+ _receiver_name,
38
+ _should_skip,
39
+ _text,
40
+ )
41
+ from code_constraints.odin.rules_extract import extract_rules
42
+
43
+ _LANG = Language(tree_sitter_odin.language())
44
+ _PARSER = Parser(_LANG)
45
+
46
+ CTOR_RULE = "no-instantiation"
47
+ FACTORY_RULE = "factory"
48
+ IMMUTABLE_RULE = "immutable"
49
+
50
+ # Odin's built-in allocators; the type is their first argument.
51
+ _ALLOCATORS = frozenset({"new", "make", "new_clone"})
52
+
53
+
54
+ def analyze(root: Path, project: Project) -> list[Finding]:
55
+ project_classes = {cls.name for cls in project.iter_classes()}
56
+ factory_index = _factory_index(project)
57
+ class_rules = {
58
+ cls.qualified_name: {r.name: r for r in cls.rules}
59
+ for cls in project.iter_classes()
60
+ }
61
+
62
+ files: list[tuple[Path, bytes, Node, str]] = []
63
+ struct_index: dict[tuple[str, str], str] = {}
64
+
65
+ for odin_file in sorted(root.rglob("*.odin")):
66
+ if _should_skip(odin_file):
67
+ continue
68
+ try:
69
+ source = odin_file.read_bytes()
70
+ except OSError:
71
+ continue
72
+ tree = _PARSER.parse(source)
73
+ rel = odin_file.relative_to(root)
74
+ package_qn = _package_name(tree.root_node, source, rel)
75
+ files.append((rel, source, tree.root_node, package_qn))
76
+ for child in tree.root_node.named_children:
77
+ if child.type not in _TYPE_DECL_TYPES:
78
+ continue
79
+ name_node = next((c for c in child.children if c.type == "identifier"), None)
80
+ if name_node is None:
81
+ continue
82
+ name = _text(name_node, source)
83
+ struct_index[(package_qn, name)] = (
84
+ f"{package_qn}.{name}" if package_qn != "__root__" else name
85
+ )
86
+
87
+ findings: list[Finding] = []
88
+ for rel, source, root_node, package_qn in files:
89
+ for child in root_node.named_children:
90
+ if child.type != "procedure_declaration":
91
+ continue
92
+ _analyze_procedure(
93
+ child, source, package_qn, rel.as_posix(), struct_index,
94
+ class_rules, project_classes, factory_index, findings,
95
+ )
96
+ return findings
97
+
98
+
99
+ def _analyze_procedure(
100
+ decl: Node,
101
+ source: bytes,
102
+ package_qn: str,
103
+ file: str,
104
+ struct_index: dict[tuple[str, str], str],
105
+ class_rules: dict[str, dict[str, RuleAnnotation]],
106
+ project_classes: set[str],
107
+ factory_index: dict[str, set[str]],
108
+ out: list[Finding],
109
+ ) -> None:
110
+ name_node = next((c for c in decl.children if c.type == "identifier"), None)
111
+ proc_node = next((c for c in decl.children if c.type == "procedure"), None)
112
+ if name_node is None or proc_node is None:
113
+ return
114
+ block = next((c for c in proc_node.children if c.type == "block"), None)
115
+ if block is None:
116
+ return
117
+
118
+ name = _text(name_node, source)
119
+ params = _parameters(proc_node, source)
120
+ owner_qn = resolve_owner(_receiver_name(params), package_qn, struct_index)
121
+ if owner_qn is None:
122
+ stem = Path(file).stem
123
+ owner_qn = f"{package_qn}.{stem}" if package_qn != "__root__" else stem
124
+ owner_name = stem
125
+ receiver = ""
126
+ else:
127
+ owner_name = owner_qn.rsplit(".", 1)[-1]
128
+ receiver = params[0].name if params else ""
129
+
130
+ proc_rules = {r.name: r for r in extract_rules(decl, source)}
131
+ owner_rules = class_rules.get(owner_qn, {})
132
+
133
+ noinst = proc_rules.get(CTOR_RULE, owner_rules.get(CTOR_RULE))
134
+ allow = literal_set(noinst.kwargs.get("allow", "")) if noinst is not None else None
135
+
136
+ for constructed, line in _constructions(block, source):
137
+ if constructed == owner_name:
138
+ # A type constructing itself is never a violation: it is how the
139
+ # `T.new(...)` / inner-constructor idiom is written, and neither
140
+ # `no_instantiation` nor `factory` is aimed at a type's own
141
+ # constructor — they guard construction by *other* code.
142
+ continue
143
+ if allow is not None and constructed not in allow:
144
+ out.append(
145
+ Finding(
146
+ rule=CTOR_RULE,
147
+ qualified_name=owner_qn,
148
+ message=(
149
+ f"'{owner_qn}.{name}' is tagged @cdec no_instantiation but "
150
+ f"constructs '{constructed}'."
151
+ ),
152
+ detail=f"{name}->{constructed}",
153
+ file=file,
154
+ line=line,
155
+ )
156
+ )
157
+ designated = factory_index.get(constructed)
158
+ if designated is not None and owner_name not in designated:
159
+ allowed = ", ".join(sorted(designated)) or "(none)"
160
+ out.append(
161
+ Finding(
162
+ rule=FACTORY_RULE,
163
+ qualified_name=owner_qn,
164
+ message=(
165
+ f"'{owner_qn}.{name}' constructs '{constructed}' outside its "
166
+ f"designated factory ({allowed})."
167
+ ),
168
+ detail=f"{name}->{constructed}",
169
+ file=file,
170
+ line=line,
171
+ )
172
+ )
173
+
174
+ if IMMUTABLE_RULE in owner_rules and receiver:
175
+ for field, line in _receiver_assignments(block, receiver, source):
176
+ out.append(
177
+ Finding(
178
+ rule=IMMUTABLE_RULE,
179
+ qualified_name=owner_qn,
180
+ message=(
181
+ f"'{owner_qn}' is tagged @cdec immutable but '{name}' "
182
+ f"reassigns field '{field}'."
183
+ ),
184
+ detail=f"{name}.{field}",
185
+ file=file,
186
+ line=line,
187
+ )
188
+ )
189
+
190
+
191
+ def _constructions(block: Node, source: bytes) -> list[tuple[str, int]]:
192
+ """Types constructed in a procedure body, with their line numbers."""
193
+ out: list[tuple[str, int]] = []
194
+ stack = [block]
195
+ while stack:
196
+ node = stack.pop()
197
+ stack.extend(node.children)
198
+ line = node.start_point[0] + 1
199
+ if node.type == "struct":
200
+ # Composite literal `Money{...}`: the type is the leading identifier.
201
+ ident = next((c for c in node.children if c.type == "identifier"), None)
202
+ if ident is not None:
203
+ out.append((_text(ident, source), line))
204
+ elif node.type == "call_expression":
205
+ callee = next((c for c in node.children if c.type == "identifier"), None)
206
+ if callee is None or _text(callee, source) not in _ALLOCATORS:
207
+ continue
208
+ args = next((c for c in node.children if c.type == "argument_list"), None)
209
+ first = args.named_children[0] if args and args.named_children else None
210
+ if first is not None:
211
+ out.append((_text(first, source).lstrip("^"), line))
212
+ return out
213
+
214
+
215
+ def _receiver_assignments(
216
+ block: Node, receiver: str, source: bytes
217
+ ) -> list[tuple[str, int]]:
218
+ """`recv.field = …` assignments inside the body."""
219
+ out: list[tuple[str, int]] = []
220
+ stack = [block]
221
+ while stack:
222
+ node = stack.pop()
223
+ stack.extend(node.children)
224
+ if node.type != "assignment_statement":
225
+ continue
226
+ target = node.named_children[0] if node.named_children else None
227
+ if target is None or target.type != "member_expression":
228
+ continue
229
+ parts = [c for c in target.children if c.type == "identifier"]
230
+ if len(parts) >= 2 and _text(parts[0], source) == receiver:
231
+ out.append((_text(parts[-1], source), node.start_point[0] + 1))
232
+ return out
233
+
234
+
235
+ def _factory_index(project: Project) -> dict[str, set[str]]:
236
+ """Created-type name -> set of class names designated to construct it."""
237
+ idx: dict[str, set[str]] = {}
238
+ for cls in project.iter_classes():
239
+ for rule in list(cls.rules) + [r for op in cls.operations for r in op.rules]:
240
+ if rule.name != FACTORY_RULE:
241
+ continue
242
+ for created in literal_set(rule.kwargs.get("creates", "")):
243
+ idx.setdefault(created, set()).add(cls.name)
244
+ return idx
@@ -0,0 +1,159 @@
1
+ """AST fingerprinting for Odin (`cdec lock`, Engine C).
2
+
3
+ Digests come from the tree-sitter tree via `core.ts_fingerprint`, so a locked
4
+ element survives reformatting and relocation within its file.
5
+
6
+ Odin-specific concerns:
7
+
8
+ * **Tags are comments**, so the `//@cdec locked` line is dropped from the digest
9
+ explicitly rather than incidentally — otherwise `include_docstrings` would let
10
+ applying a lock change the very digest it records.
11
+ * **Target names must match the UML model**, because a target an agent reads
12
+ from `cdec lock list` has to be the target `cdec lock set` accepts. Procedures
13
+ are therefore resolved to their receiver struct exactly as the parser does,
14
+ which needs the same two-phase walk (structs first, then procedures) and the
15
+ same helpers — imported from `odin.parser` rather than re-derived, so the two
16
+ can't drift apart.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ import tree_sitter_odin
24
+ from tree_sitter import Language, Node, Parser
25
+
26
+ from code_constraints.core.ts_fingerprint import digest_members
27
+ from code_constraints.lock.model import LOCK_RULE, LockTarget
28
+ from code_constraints.odin.parser import (
29
+ _TYPE_DECL_TYPES,
30
+ _package_name,
31
+ _parameters,
32
+ _receiver_name,
33
+ _should_skip,
34
+ _text,
35
+ )
36
+ from code_constraints.odin.rules_extract import comment_is_rule_tag, extract_rules
37
+
38
+ DIGEST_ALGO = "odin-ts/1"
39
+
40
+ _LANG = Language(tree_sitter_odin.language())
41
+ _PARSER = Parser(_LANG)
42
+
43
+
44
+ def collect_lockables(
45
+ root: str | Path, *, include_docstrings: bool = False
46
+ ) -> list[LockTarget]:
47
+ root_path = Path(root).resolve()
48
+ files: list[tuple[Path, bytes, Node, str]] = []
49
+ # (package_qn, struct name) -> qualified name, for receiver resolution.
50
+ struct_index: dict[tuple[str, str], str] = {}
51
+ out: list[LockTarget] = []
52
+
53
+ # Phase 1: every type declaration, indexed for phase 2.
54
+ for odin_file in sorted(root_path.rglob("*.odin")):
55
+ if _should_skip(odin_file):
56
+ continue
57
+ try:
58
+ source = odin_file.read_bytes()
59
+ except OSError:
60
+ continue
61
+ tree = _PARSER.parse(source)
62
+ rel = odin_file.relative_to(root_path)
63
+ package_qn = _package_name(tree.root_node, source, rel)
64
+ files.append((rel, source, tree.root_node, package_qn))
65
+
66
+ for child in tree.root_node.named_children:
67
+ if child.type not in _TYPE_DECL_TYPES:
68
+ continue
69
+ name_node = next((c for c in child.children if c.type == "identifier"), None)
70
+ if name_node is None:
71
+ continue
72
+ name = _text(name_node, source)
73
+ qn = f"{package_qn}.{name}" if package_qn != "__root__" else name
74
+ struct_index[(package_qn, name)] = qn
75
+ out.append(
76
+ _target(
77
+ [(child, source)], qn, "class", rel.as_posix(), include_docstrings
78
+ )
79
+ )
80
+
81
+ # Phase 2: procedures, grouped by owner so same-named siblings share a target.
82
+ groups: dict[str, list[tuple[Node, bytes]]] = {}
83
+ locations: dict[str, str] = {}
84
+ for rel, source, root_node, package_qn in files:
85
+ for child in root_node.named_children:
86
+ if child.type != "procedure_declaration":
87
+ continue
88
+ name_node = next((c for c in child.children if c.type == "identifier"), None)
89
+ proc_node = next((c for c in child.children if c.type == "procedure"), None)
90
+ if name_node is None or proc_node is None:
91
+ continue
92
+ owner = _owner_qn(proc_node, source, package_qn, struct_index, rel)
93
+ target = f"{owner}.{_text(name_node, source)}"
94
+ groups.setdefault(target, []).append((child, source))
95
+ locations.setdefault(target, rel.as_posix())
96
+
97
+ for target, members in groups.items():
98
+ out.append(
99
+ _target(members, target, "method", locations[target], include_docstrings)
100
+ )
101
+
102
+ return out
103
+
104
+
105
+ def _owner_qn(
106
+ proc_node: Node,
107
+ source: bytes,
108
+ package_qn: str,
109
+ struct_index: dict[tuple[str, str], str],
110
+ rel: Path,
111
+ ) -> str:
112
+ """The receiver struct's qualified name, or the file's synthetic module class."""
113
+ params = _parameters(proc_node, source)
114
+ name = _receiver_name(params)
115
+ if name:
116
+ same_package = struct_index.get((package_qn, name))
117
+ if same_package is not None:
118
+ return same_package
119
+ matches = [qn for (_pkg, n), qn in struct_index.items() if n == name]
120
+ if len(matches) == 1:
121
+ return matches[0]
122
+ return f"{package_qn}.{rel.stem}" if package_qn != "__root__" else rel.stem
123
+
124
+
125
+ def _target(
126
+ members: list[tuple[Node, bytes]],
127
+ target: str,
128
+ kind: str,
129
+ file: str,
130
+ include_docstrings: bool,
131
+ ) -> LockTarget:
132
+ def drop(node: Node, source: bytes) -> bool:
133
+ if node.type != "comment":
134
+ return False
135
+ # A rule tag is never part of the implementation, whatever the
136
+ # docstring setting: applying or removing `//@cdec locked` must leave
137
+ # the digest of the body it guards untouched.
138
+ if comment_is_rule_tag(node, source):
139
+ return True
140
+ return not include_docstrings
141
+
142
+ declared = False
143
+ params: dict[str, str] = {}
144
+ for node, source in members:
145
+ for rule in extract_rules(node, source):
146
+ if rule.name == LOCK_RULE:
147
+ declared = True
148
+ params = {**rule.kwargs, **params} if params else dict(rule.kwargs)
149
+
150
+ return LockTarget(
151
+ target=target,
152
+ kind=kind, # type: ignore[arg-type]
153
+ digest=digest_members(members, drop),
154
+ algo=DIGEST_ALGO,
155
+ file=file,
156
+ line=members[0][0].start_point[0] + 1,
157
+ declared=declared,
158
+ params=params,
159
+ )