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,208 @@
1
+ """`.cdec/rules.yaml` — the one file a project commits.
2
+
3
+ Everything the tool needs to gate a codebase lives here: the project settings,
4
+ the rules that are enforced, the exceptions that were granted, and the digests
5
+ of frozen implementations. One file, one diff to review, one thing to put behind
6
+ CODEOWNERS.
7
+
8
+ That creates a problem this module exists to solve. The `rules:` section is
9
+ hand-written and carries the explanations that make a failed build teach
10
+ something; the `exceptions:` and `locks:` sections are written by the tool. A
11
+ naive ``yaml.safe_dump`` of the whole document would silently delete every
12
+ comment and reflow every ``message: |`` block the first time anyone ran
13
+ ``cdec check --automatic-exceptions``.
14
+
15
+ So writes are *surgical*. The tool-written sections live at the end of the file
16
+ under a marker line; a write keeps every byte above the marker verbatim and
17
+ regenerates only what is below it. Reads are ordinary ``yaml.safe_load`` over
18
+ the whole document, so the split is invisible to everything else.
19
+
20
+ Shape::
21
+
22
+ language: python
23
+ source: src
24
+
25
+ rules:
26
+ - id: catalog-is-a-leaf
27
+ type: forbidden-package-references
28
+ ...
29
+
30
+ # >>> cdec: managed section ...
31
+ exceptions:
32
+ - key: V-1A2B3C4D
33
+ ...
34
+ locks:
35
+ - target: orders.Receipt.formatted
36
+ ...
37
+
38
+ Deliberately engine-agnostic: this module deals in plain mappings and imports
39
+ nothing from `lint`, `enforce`, `lock` or `waivers`, so the engines that write
40
+ into the file stay decoupled from each other.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import re
46
+ from pathlib import Path
47
+ from typing import Any
48
+
49
+ import yaml
50
+
51
+ RULES_FILENAME = "rules.yaml"
52
+
53
+ #: Top-level keys owned by the tool. Everything else in the file is the user's.
54
+ MANAGED_KEYS: tuple[str, ...] = ("exceptions", "locks")
55
+
56
+ MANAGED_MARKER = (
57
+ "# >>> cdec: managed section — rewritten by `cdec check --automatic-exceptions`\n"
58
+ "# >>> and by `cdec exceptions ...`. Edit the rules above this line, not below it.\n"
59
+ )
60
+
61
+ # A top-level YAML key: no leading whitespace, an identifier, a colon.
62
+ _TOP_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*)\s*:")
63
+
64
+
65
+ class RulesFileError(ValueError):
66
+ """Raised when `rules.yaml` exists but can't be interpreted."""
67
+
68
+
69
+ def load_document(path: Path) -> dict[str, Any]:
70
+ """Read the whole file as a mapping. A missing file is an empty document."""
71
+ if not path.is_file():
72
+ return {}
73
+ with path.open("r", encoding="utf-8") as fh:
74
+ try:
75
+ raw = yaml.safe_load(fh) or {}
76
+ except yaml.YAMLError as exc:
77
+ raise RulesFileError(f"{path}: not valid YAML ({exc})") from exc
78
+ if not isinstance(raw, dict):
79
+ raise RulesFileError(f"{path}: top-level must be a mapping")
80
+ return raw
81
+
82
+
83
+ def read_section(path: Path, key: str) -> Any:
84
+ """One top-level section, or None when absent."""
85
+ return load_document(path).get(key)
86
+
87
+
88
+ def write_sections(path: Path, sections: dict[str, Any]) -> None:
89
+ """Replace the named tool-owned sections, preserving everything else byte
90
+ for byte.
91
+
92
+ Only the keys in `sections` are touched — writing `exceptions` leaves a
93
+ `locks` section exactly as it was, so the two engines can write the same
94
+ file without stepping on each other.
95
+ """
96
+ unknown = set(sections) - set(MANAGED_KEYS)
97
+ if unknown:
98
+ raise RulesFileError(
99
+ f"refusing to write non-managed section(s) {sorted(unknown)} into {path}"
100
+ )
101
+
102
+ text = path.read_text(encoding="utf-8") if path.is_file() else ""
103
+ prefix, existing = _split(text)
104
+
105
+ merged = dict(existing)
106
+ for key, value in sections.items():
107
+ if value:
108
+ merged[key] = value
109
+ else:
110
+ merged.pop(key, None)
111
+
112
+ body = ""
113
+ for key in MANAGED_KEYS:
114
+ if key in merged:
115
+ body += yaml.safe_dump(
116
+ {key: merged[key]}, sort_keys=False, default_flow_style=False,
117
+ allow_unicode=True, width=100000,
118
+ )
119
+
120
+ if prefix and not prefix.endswith("\n"):
121
+ prefix += "\n"
122
+ out = prefix
123
+ if body:
124
+ if out and not out.endswith("\n\n"):
125
+ out += "\n"
126
+ out += MANAGED_MARKER + body
127
+ elif not out:
128
+ out = ""
129
+
130
+ path.parent.mkdir(parents=True, exist_ok=True)
131
+ path.write_text(out, encoding="utf-8")
132
+
133
+
134
+ # ---------- internals ----------
135
+
136
+ def _split(text: str) -> tuple[str, dict[str, Any]]:
137
+ """Split the document into (verbatim prefix, parsed managed sections).
138
+
139
+ The prefix is everything the user owns. Managed sections are removed from it
140
+ wherever they appear — normally below the marker, but a hand-placed
141
+ `exceptions:` higher up is lifted too, so a write can never produce a
142
+ duplicate top-level key.
143
+ """
144
+ lines = text.splitlines(keepends=True)
145
+ spans = _top_level_spans(lines)
146
+
147
+ managed_text = "".join(
148
+ "".join(lines[start:end])
149
+ for key, start, end in spans
150
+ if key in MANAGED_KEYS
151
+ )
152
+ drop: set[int] = set()
153
+ for key, start, end in spans:
154
+ if key in MANAGED_KEYS:
155
+ drop.update(range(start, end))
156
+
157
+ prefix_lines = [line for i, line in enumerate(lines) if i not in drop]
158
+ prefix = "".join(prefix_lines)
159
+ # The marker only describes the block below it; without one it is noise.
160
+ prefix = prefix.replace(MANAGED_MARKER, "")
161
+ for marker_line in MANAGED_MARKER.splitlines(keepends=True):
162
+ prefix = prefix.replace(marker_line, "")
163
+ prefix = prefix.rstrip("\n")
164
+ if prefix:
165
+ prefix += "\n"
166
+
167
+ if not managed_text.strip():
168
+ return prefix, {}
169
+ try:
170
+ parsed = yaml.safe_load(managed_text) or {}
171
+ except yaml.YAMLError:
172
+ # Unparseable managed text is regenerated from scratch rather than
173
+ # merged; the caller is handing us the authoritative content anyway.
174
+ return prefix, {}
175
+ return prefix, parsed if isinstance(parsed, dict) else {}
176
+
177
+
178
+ def _top_level_spans(lines: list[str]) -> list[tuple[str, int, int]]:
179
+ """(key, start_index, end_index) for every top-level mapping key.
180
+
181
+ A span runs from the key line to just before the next top-level key (or
182
+ EOF), so it carries the key's whole nested block. Comment lines immediately
183
+ above a key belong to that key, so they travel with it.
184
+ """
185
+ starts: list[tuple[str, int]] = []
186
+ for i, line in enumerate(lines):
187
+ match = _TOP_KEY_RE.match(line)
188
+ if match is None:
189
+ continue
190
+ starts.append((match.group(1), _comment_block_start(lines, i)))
191
+
192
+ spans: list[tuple[str, int, int]] = []
193
+ for pos, (key, start) in enumerate(starts):
194
+ end = starts[pos + 1][1] if pos + 1 < len(starts) else len(lines)
195
+ spans.append((key, start, end))
196
+ return spans
197
+
198
+
199
+ def _comment_block_start(lines: list[str], key_index: int) -> int:
200
+ """Index of the first line of the comment block attached to `key_index`."""
201
+ i = key_index
202
+ while i > 0:
203
+ previous = lines[i - 1].strip()
204
+ if previous.startswith("#"):
205
+ i -= 1
206
+ continue
207
+ break
208
+ return i
@@ -0,0 +1,114 @@
1
+ """Parse XML-style comment tags from source code.
2
+
3
+ Recognised forms (after the comment prefix is stripped):
4
+
5
+ <uml-class />
6
+ <uml-activity name="checkout" granularity="control-flow">
7
+ </uml-activity>
8
+ <uml-sequence name="login" root="Handle">
9
+ </uml-sequence>
10
+
11
+ The caller provides the comment prefix (e.g. `//` for C#, `#` for Python).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import xml.etree.ElementTree as ET
18
+ from dataclasses import dataclass, field
19
+ from typing import Literal
20
+
21
+ TagKind = Literal["uml-class", "uml-activity", "uml-sequence"]
22
+ _VALID_KINDS: set[str] = {"uml-class", "uml-activity", "uml-sequence"}
23
+
24
+
25
+ @dataclass
26
+ class TagInstance:
27
+ """A matched tag span. `start_line` and `end_line` are 1-indexed; the
28
+ closed range covers the lines between the opening and closing tags
29
+ (inclusive of the lines containing the tags themselves)."""
30
+ kind: TagKind
31
+ name: str
32
+ attributes: dict[str, str] = field(default_factory=dict)
33
+ start_line: int = 0
34
+ end_line: int = 0
35
+ self_closed: bool = False
36
+
37
+
38
+ _TAG_LINE_RE = re.compile(r"<\s*/?\s*uml-[a-zA-Z-]+\b[^>]*?/?\s*>")
39
+
40
+
41
+ def find_tags(source: str, comment_prefix: str) -> list[TagInstance]:
42
+ """Scan `source` (full file text) and return tag spans.
43
+
44
+ Unbalanced tags (open without close, or vice versa) are skipped silently;
45
+ the parser is intentionally forgiving so a malformed comment never breaks
46
+ the rest of the diagram generation.
47
+ """
48
+ prefix = comment_prefix.strip()
49
+ open_stack: list[tuple[str, dict[str, str], int]] = []
50
+ result: list[TagInstance] = []
51
+
52
+ for lineno, line in enumerate(source.splitlines(), start=1):
53
+ stripped = line.strip()
54
+ if not stripped.startswith(prefix):
55
+ continue
56
+ rest = stripped[len(prefix) :].strip()
57
+ match = _TAG_LINE_RE.search(rest)
58
+ if not match:
59
+ continue
60
+ tag_text = match.group(0)
61
+
62
+ if tag_text.startswith("</"):
63
+ kind = _extract_kind(tag_text)
64
+ if kind not in _VALID_KINDS:
65
+ continue
66
+ if open_stack and open_stack[-1][0] == kind:
67
+ opened_kind, attrs, opened_line = open_stack.pop()
68
+ result.append(
69
+ TagInstance(
70
+ kind=opened_kind, # type: ignore[arg-type]
71
+ name=attrs.get("name", ""),
72
+ attributes=attrs,
73
+ start_line=opened_line,
74
+ end_line=lineno,
75
+ self_closed=False,
76
+ )
77
+ )
78
+ continue
79
+
80
+ is_self_closing = tag_text.rstrip().endswith("/>")
81
+ # ET requires balanced XML — rewrite an open tag as self-closing so
82
+ # we can reuse it to parse attributes.
83
+ parse_text = tag_text if is_self_closing else tag_text.rstrip()[:-1] + "/>"
84
+ try:
85
+ element = ET.fromstring(parse_text)
86
+ except ET.ParseError:
87
+ continue
88
+ kind = element.tag
89
+ if kind not in _VALID_KINDS:
90
+ continue
91
+ attrs = dict(element.attrib)
92
+ if is_self_closing:
93
+ result.append(
94
+ TagInstance(
95
+ kind=kind, # type: ignore[arg-type]
96
+ name=attrs.get("name", ""),
97
+ attributes=attrs,
98
+ start_line=lineno,
99
+ end_line=lineno,
100
+ self_closed=True,
101
+ )
102
+ )
103
+ else:
104
+ open_stack.append((kind, attrs, lineno))
105
+
106
+ return result
107
+
108
+
109
+ def _extract_kind(tag_text: str) -> str:
110
+ """Pull the bare tag name out of `</uml-activity>` etc."""
111
+ cleaned = tag_text.strip().lstrip("<").rstrip(">").lstrip("/").rstrip("/").strip()
112
+ if " " in cleaned:
113
+ cleaned = cleaned.split(" ", 1)[0]
114
+ return cleaned
@@ -0,0 +1,88 @@
1
+ """Canonical AST serialisation for tree-sitter-backed lock fingerprints.
2
+
3
+ The shared half of Engine C (`cdec lock`) for every tree-sitter language. It
4
+ answers only "what string represents this subtree"; each language's
5
+ `fingerprint.py` owns discovery, target naming, and which nodes to drop.
6
+
7
+ The invariants a fingerprinter must preserve — the same ones documented on
8
+ `code_constraints.csharp.fingerprint`, which predates this module and keeps its
9
+ own copy:
10
+
11
+ * **Anonymous children are walked.** Operators and punctuation are anonymous
12
+ tokens, so skipping them would hash `a + b` and `a - b` identically.
13
+ * **Whitespace never appears.** tree-sitter emits no nodes for indentation or
14
+ newlines, so a locked body survives reformatting and relocation for free.
15
+ * **Comments are dropped** unless the caller keeps them, and the rule *tag*
16
+ itself is always dropped — applying or removing a lock must not change the
17
+ digest of the body it guards.
18
+ * **Changing this serialiser must bump the caller's algo id.** A mismatch then
19
+ surfaces as an `algo-mismatch` violation ("re-baseline") rather than as a
20
+ false "implementation changed".
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from hashlib import sha256
26
+ from typing import Callable, Sequence
27
+
28
+ from tree_sitter import Node
29
+
30
+ # Returns True for a node that must not contribute to the digest. Takes the
31
+ # source alongside the node because a group's members can come from different
32
+ # files (Odin and Julia declare a type's operations at package scope, so two
33
+ # same-named members need not share a file).
34
+ DropFn = Callable[[Node, bytes], bool]
35
+
36
+ # Extra text folded into a member's digest beyond its serialised subtree. Lets a
37
+ # language account for something outside the node it digests — Julia uses it to
38
+ # keep non-rule macro wrappers significant while digesting the unwrapped
39
+ # definition. Return "" to add nothing.
40
+ ExtraFn = Callable[[Node, bytes], str]
41
+
42
+
43
+ def canonical(node: Node, source: bytes, drop: DropFn) -> str:
44
+ """Serialise `node` to a canonical, position-independent string."""
45
+ if drop(node, source):
46
+ return ""
47
+ if node.child_count == 0:
48
+ text = _text(node, source).strip()
49
+ return f"({node.type} {text!r})" if node.is_named else f"({text!r})"
50
+ parts = [p for p in (canonical(c, source, drop) for c in node.children) if p]
51
+ if not parts:
52
+ # Every child was dropped; keep the node's own identity so an emptied
53
+ # wrapper still differs from the wrapper being absent.
54
+ return f"({node.type})"
55
+ return f"({node.type} {' '.join(parts)})"
56
+
57
+
58
+ def digest_members(
59
+ members: Sequence[tuple[Node, bytes]], drop: DropFn, extra: ExtraFn | None = None
60
+ ) -> str:
61
+ """Digest a group of same-named members as one target.
62
+
63
+ Overloads, `@property`/setter pairs, and multiple-dispatch methods collapse
64
+ into a single digest over their sorted member digests, so adding an overload
65
+ to a locked name is a violation in its own right and no ordinal
66
+ disambiguator is needed. Sorting also makes the digest independent of the
67
+ order the files happened to be walked in.
68
+ """
69
+ digests = sorted(
70
+ sha256(
71
+ (canonical(node, source, drop) + _suffix(extra, node, source)).encode("utf-8")
72
+ ).hexdigest()
73
+ for node, source in members
74
+ )
75
+ if len(digests) == 1:
76
+ return digests[0]
77
+ return sha256(("group:" + "|".join(digests)).encode("utf-8")).hexdigest()
78
+
79
+
80
+ def _suffix(extra: ExtraFn | None, node: Node, source: bytes) -> str:
81
+ if extra is None:
82
+ return ""
83
+ text = extra(node, source)
84
+ return f"|extra:{text}" if text else ""
85
+
86
+
87
+ def _text(node: Node, source: bytes) -> str:
88
+ return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")