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,69 @@
1
+ """Rule: forbid cycles in the package-dependency graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable
6
+
7
+ from code_constraints.lint.rules import register
8
+ from code_constraints.lint.rules.base import Rule, RuleContext, Violation
9
+
10
+
11
+ @register("no-cyclic-package-dependencies")
12
+ class NoCyclicPackageDependencies(Rule):
13
+ """No options. Walks the derived package-edge graph and reports every
14
+ elementary cycle as a single violation against the alphabetically-first
15
+ package in the cycle."""
16
+
17
+ def check(self, ctx: RuleContext) -> Iterable[Violation]:
18
+ graph = {src: set(t for t in tgts if t != src) for src, tgts in ctx.outgoing_pkg_refs.items()}
19
+ reported: set[tuple[str, ...]] = set()
20
+ for cycle in _find_cycles(graph):
21
+ cycle = tuple(cycle)
22
+ # Canonicalise: rotate so the lex-smallest node is first, so
23
+ # `[A,B,C]` and `[B,C,A]` collapse to one report.
24
+ min_idx = min(range(len(cycle)), key=lambda i: cycle[i])
25
+ normalised = cycle[min_idx:] + cycle[:min_idx]
26
+ if normalised in reported:
27
+ continue
28
+ reported.add(normalised)
29
+ head = normalised[0]
30
+ if self.is_ignored(head):
31
+ continue
32
+ chain = " -> ".join(list(normalised) + [normalised[0]])
33
+ yield self.emit(
34
+ qualified_name=head,
35
+ signature=chain,
36
+ message=self.message_for(cycle=chain)
37
+ or f"Cyclic package dependency: {chain}.",
38
+ )
39
+
40
+
41
+ def _find_cycles(graph: dict[str, set[str]]) -> list[list[str]]:
42
+ """Tarjan-style enumeration of simple cycles. Keeps things small — only
43
+ used over a package graph whose node count is order-of-magnitudes smaller
44
+ than the class graph."""
45
+ cycles: list[list[str]] = []
46
+ nodes = list(graph.keys())
47
+ blocked: set[str] = set()
48
+ stack: list[str] = []
49
+
50
+ def dfs(node: str, start: str) -> bool:
51
+ found = False
52
+ stack.append(node)
53
+ blocked.add(node)
54
+ for nb in graph.get(node, set()):
55
+ if nb == start and len(stack) >= 1:
56
+ cycles.append(list(stack))
57
+ found = True
58
+ elif nb not in blocked:
59
+ if dfs(nb, start):
60
+ found = True
61
+ stack.pop()
62
+ if found:
63
+ blocked.discard(node)
64
+ return found
65
+
66
+ for start in nodes:
67
+ blocked.clear()
68
+ dfs(start, start)
69
+ return cycles
@@ -0,0 +1,98 @@
1
+ """Rule: flag classes that have no incoming references."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable
6
+
7
+ from code_constraints.core.model import Class, DiffStatus
8
+ from code_constraints.lint.rules import register
9
+ from code_constraints.lint.rules.base import Rule, RuleContext, Violation, match_any_glob
10
+
11
+ # Framework base classes whose subclasses are instantiated by the framework
12
+ # (Unity scenes, the editor, networking) rather than by project code, so they
13
+ # legitimately have no incoming reference. Treated as entry points.
14
+ _DEFAULT_FRAMEWORK_BASES = (
15
+ "MonoBehaviour",
16
+ "ScriptableObject",
17
+ "NetworkBehaviour",
18
+ "StateMachineBehaviour",
19
+ "Editor",
20
+ "EditorWindow",
21
+ "PropertyDrawer",
22
+ "ScriptableWizard",
23
+ )
24
+
25
+
26
+ @register("dangling-classes")
27
+ class DanglingClasses(Rule):
28
+ """A class is "dangling" when no other project class references it
29
+ (no attribute, method signature, method-body usage, or base resolves to it)
30
+ and it isn't in the configured `entry_points` allow-list.
31
+
32
+ Classes that derive (transitively) from a framework base such as
33
+ `MonoBehaviour` are treated as entry points — the framework instantiates
34
+ them, so the absence of an in-project reference is expected. Override the
35
+ base list with the `framework_bases` option.
36
+
37
+ Snapshot rule: by default checks every class. If `scope: diff`, only checks
38
+ classes whose status is ADDED or CHANGED (i.e. don't fail on classes that
39
+ were already dangling before the change).
40
+ """
41
+
42
+ def check(self, ctx: RuleContext) -> Iterable[Violation]:
43
+ entry_points: list[str] = list(self.options.get("entry_points") or [])
44
+ framework_bases: set[str] = set(
45
+ self.options.get("framework_bases") or _DEFAULT_FRAMEWORK_BASES
46
+ )
47
+ for cls in ctx.project.iter_classes():
48
+ qn = cls.qualified_name
49
+ if self.is_ignored(qn):
50
+ continue
51
+ if self.scope == "diff" and ctx.has_diff:
52
+ if cls.status not in (DiffStatus.ADDED, DiffStatus.CHANGED):
53
+ continue
54
+ # Skip removed ghost classes — they're already gone.
55
+ if cls.status == DiffStatus.REMOVED:
56
+ continue
57
+ if match_any_glob(qn, entry_points):
58
+ continue
59
+ if match_any_glob(cls.name, entry_points):
60
+ continue
61
+ if _derives_from_framework(cls, ctx, framework_bases):
62
+ continue
63
+ incoming = ctx.incoming_refs.get(qn, set())
64
+ # Self-references don't count as incoming.
65
+ incoming = {ref for ref in incoming if ref != qn}
66
+ if not incoming:
67
+ yield self.emit(
68
+ qualified_name=qn,
69
+ message=self.message_for(qualified_name=qn)
70
+ or f"Class '{qn}' has no incoming references (dangling).",
71
+ location=cls.location,
72
+ )
73
+
74
+
75
+ def _derives_from_framework(
76
+ cls: Class, ctx: RuleContext, framework_bases: set[str]
77
+ ) -> bool:
78
+ """True if `cls` derives (directly or transitively through project
79
+ ancestors) from any base name in `framework_bases`."""
80
+ stack: list[str] = list(cls.bases)
81
+ seen: set[str] = set()
82
+ while stack:
83
+ base = stack.pop()
84
+ if base in seen:
85
+ continue
86
+ seen.add(base)
87
+ simple = base.split(".")[-1].split("<")[0]
88
+ if simple in framework_bases:
89
+ return True
90
+ parent = ctx.class_by_qn.get(base)
91
+ if parent is None:
92
+ for parent_qn, candidate in ctx.class_by_qn.items():
93
+ if parent_qn.split(".")[-1] == simple:
94
+ parent = candidate
95
+ break
96
+ if parent is not None:
97
+ stack.extend(parent.bases)
98
+ return False
@@ -0,0 +1,47 @@
1
+ """Rule: forbid packages in `from` from referencing packages in `to`.
2
+
3
+ Edges between packages are derived from class-level outgoing references
4
+ (aggregated by containing package), matching the logic in
5
+ `code_constraints.core.graph_model.build_package_graph`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Iterable
11
+
12
+ from code_constraints.lint.rules import register
13
+ from code_constraints.lint.rules.base import Rule, RuleContext, Violation, match_any_glob
14
+
15
+
16
+ @register("forbidden-package-references")
17
+ class ForbiddenPackageReferences(Rule):
18
+ """Options:
19
+ from: list[str] of package qualified-name globs
20
+ to: list[str] of package qualified-name globs
21
+ """
22
+
23
+ def check(self, ctx: RuleContext) -> Iterable[Violation]:
24
+ from_patterns: list[str] = list(self.options.get("from") or [])
25
+ to_patterns: list[str] = list(self.options.get("to") or [])
26
+ if not from_patterns or not to_patterns:
27
+ return
28
+ for src_pkg, tgt_pkgs in ctx.outgoing_pkg_refs.items():
29
+ if self.is_ignored(src_pkg):
30
+ continue
31
+ if not match_any_glob(src_pkg, from_patterns):
32
+ continue
33
+ for tgt_pkg in tgt_pkgs:
34
+ if tgt_pkg == src_pkg:
35
+ continue
36
+ if self.is_ignored(tgt_pkg):
37
+ continue
38
+ if not match_any_glob(tgt_pkg, to_patterns):
39
+ continue
40
+ yield self.emit(
41
+ qualified_name=src_pkg,
42
+ signature=f"->{tgt_pkg}",
43
+ message=self.message_for(
44
+ qualified_name=src_pkg, source=src_pkg, target=tgt_pkg
45
+ )
46
+ or f"Package '{src_pkg}' is not allowed to reference package '{tgt_pkg}'.",
47
+ )
@@ -0,0 +1,48 @@
1
+ """Rule: forbid classes in `from` from referencing classes in `to`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable
6
+
7
+ from code_constraints.core.model import DiffStatus
8
+ from code_constraints.lint.rules import register
9
+ from code_constraints.lint.rules.base import Rule, RuleContext, Violation, match_any_glob
10
+
11
+
12
+ @register("forbidden-references")
13
+ class ForbiddenReferences(Rule):
14
+ """Options:
15
+ from: list[str] of qualified-name globs (source side)
16
+ to: list[str] of qualified-name globs (target side)
17
+ """
18
+
19
+ def check(self, ctx: RuleContext) -> Iterable[Violation]:
20
+ from_patterns: list[str] = list(self.options.get("from") or [])
21
+ to_patterns: list[str] = list(self.options.get("to") or [])
22
+ if not from_patterns or not to_patterns:
23
+ return
24
+ for cls in ctx.project.iter_classes():
25
+ if cls.status == DiffStatus.REMOVED:
26
+ continue
27
+ src_qn = cls.qualified_name
28
+ if self.is_ignored(src_qn):
29
+ continue
30
+ if not match_any_glob(src_qn, from_patterns):
31
+ continue
32
+ if self.scope == "diff" and ctx.has_diff:
33
+ if cls.status not in (DiffStatus.ADDED, DiffStatus.CHANGED):
34
+ continue
35
+ for tgt_qn in ctx.outgoing_refs.get(src_qn, set()):
36
+ if self.is_ignored(tgt_qn):
37
+ continue
38
+ if not match_any_glob(tgt_qn, to_patterns):
39
+ continue
40
+ yield self.emit(
41
+ qualified_name=src_qn,
42
+ signature=f"->{tgt_qn}",
43
+ message=self.message_for(
44
+ qualified_name=src_qn, source=src_qn, target=tgt_qn
45
+ )
46
+ or f"'{src_qn}' is not allowed to reference '{tgt_qn}'.",
47
+ location=cls.location,
48
+ )
@@ -0,0 +1,67 @@
1
+ """Rule: forbid changes (add/remove/change) to attributes or operations on
2
+ a selected set of classes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Iterable
7
+
8
+ from code_constraints.core.model import DiffStatus
9
+ from code_constraints.lint.rules import register
10
+ from code_constraints.lint.rules.base import Rule, RuleContext, Violation, match_any_glob
11
+
12
+
13
+ @register("frozen-members")
14
+ class FrozenMembers(Rule):
15
+ """Options:
16
+ classes: list[str] of qualified-name globs identifying the locked classes
17
+ members: optional list[str] of member-name or signature globs (default: all)
18
+ kinds: optional list, subset of ["attribute", "operation"] (default: both)
19
+ """
20
+
21
+ default_scope = "diff"
22
+
23
+ def check(self, ctx: RuleContext) -> Iterable[Violation]:
24
+ if not ctx.has_diff:
25
+ return
26
+ class_patterns: list[str] = list(self.options.get("classes") or ["*"])
27
+ member_patterns: list[str] = list(self.options.get("members") or ["*"])
28
+ kinds: list[str] = list(self.options.get("kinds") or ["attribute", "operation"])
29
+
30
+ for cls in ctx.project.iter_classes():
31
+ if self.is_ignored(cls.qualified_name):
32
+ continue
33
+ if not match_any_glob(cls.qualified_name, class_patterns):
34
+ continue
35
+ members: list[tuple[str, object]] = []
36
+ if "attribute" in kinds:
37
+ members.extend(("attribute", a) for a in cls.attributes)
38
+ if "operation" in kinds:
39
+ members.extend(("operation", o) for o in cls.operations)
40
+ for kind, member in members:
41
+ status = getattr(member, "status", DiffStatus.UNCHANGED)
42
+ if status == DiffStatus.UNCHANGED:
43
+ continue
44
+ name = getattr(member, "name", "")
45
+ sig = member.signature() if hasattr(member, "signature") else name
46
+ if not (
47
+ match_any_glob(name, member_patterns)
48
+ or match_any_glob(sig, member_patterns)
49
+ ):
50
+ continue
51
+ action = {
52
+ DiffStatus.ADDED: "added",
53
+ DiffStatus.REMOVED: "removed",
54
+ DiffStatus.CHANGED: "changed",
55
+ }.get(status, str(status))
56
+ yield self.emit(
57
+ qualified_name=cls.qualified_name,
58
+ signature=f"{kind}:{sig}",
59
+ message=self.message_for(
60
+ qualified_name=cls.qualified_name,
61
+ member=sig,
62
+ kind=kind,
63
+ action=action,
64
+ )
65
+ or f"{kind.capitalize()} '{sig}' on '{cls.qualified_name}' was {action}.",
66
+ location=cls.location,
67
+ )
@@ -0,0 +1,105 @@
1
+ """Rule: architectural-rule tags present in the baseline must not be removed
2
+ or modified in the current model.
3
+
4
+ This is the *drift* half of architectural-rule enforcement (Engine A). It never
5
+ inspects method bodies — it only compares the tag sets recorded on the baseline
6
+ (OLD) elements against the current (NEW) ones. The semantic question "does the
7
+ code actually obey the tag" is the job of the decoupled `cdec enforce` command.
8
+
9
+ A frozen tag is satisfied only when an identical tag (same name, same args, same
10
+ kwargs) still exists on the same element. Removing a tag or weakening its
11
+ parameters therefore fires — exactly the drift the user wants CI to block.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Iterable
17
+
18
+ from code_constraints.core.model import Class, Operation
19
+ from code_constraints.lint.rules import register
20
+ from code_constraints.lint.rules.base import Rule, RuleContext, Violation, match_any_glob
21
+
22
+
23
+ @register("frozen-rules")
24
+ class FrozenRules(Rule):
25
+ """Options:
26
+ classes: list[str] of qualified-name globs identifying locked classes
27
+ (default: all classes)
28
+
29
+ Scope must be `diff` — it needs a baseline to compare against.
30
+ """
31
+
32
+ default_scope = "diff"
33
+
34
+ def check(self, ctx: RuleContext) -> Iterable[Violation]:
35
+ if not ctx.has_diff:
36
+ return
37
+ class_patterns: list[str] = list(self.options.get("classes") or ["*"])
38
+
39
+ for qn, base_cls in ctx.baseline_class_by_qn.items():
40
+ if self.is_ignored(qn):
41
+ continue
42
+ if not match_any_glob(qn, class_patterns):
43
+ continue
44
+ cur_cls = ctx.class_by_qn.get(qn)
45
+ if cur_cls is None:
46
+ # Whole class removed — that's `no-removed-classes`' job.
47
+ continue
48
+
49
+ yield from self._check_element(
50
+ qn, signature=None, base=base_cls, cur=cur_cls, location=cur_cls.location
51
+ )
52
+
53
+ cur_ops = {o.signature(): o for o in cur_cls.operations}
54
+ for base_op in base_cls.operations:
55
+ if not base_op.rules:
56
+ continue
57
+ cur_op = cur_ops.get(base_op.signature())
58
+ if cur_op is None:
59
+ # Operation removed — `frozen-members` handles that.
60
+ continue
61
+ yield from self._check_element(
62
+ qn,
63
+ signature=f"operation:{base_op.signature()}",
64
+ base=base_op,
65
+ cur=cur_op,
66
+ location=cur_cls.location,
67
+ )
68
+
69
+ def _check_element(
70
+ self,
71
+ qn: str,
72
+ *,
73
+ signature: str | None,
74
+ base: Class | Operation,
75
+ cur: Class | Operation,
76
+ location,
77
+ ) -> Iterable[Violation]:
78
+ cur_rules = list(cur.rules)
79
+ cur_by_name = {r.name: r for r in cur_rules}
80
+ where = signature.split(":", 1)[1] if signature else qn
81
+ for base_rule in base.rules:
82
+ if base_rule in cur_rules:
83
+ continue # identical tag still present
84
+ if base_rule.name in cur_by_name:
85
+ action = "weakened"
86
+ detail = (
87
+ f"architectural tag @{base_rule.name} on '{where}' was weakened "
88
+ f"(params changed from the baseline)."
89
+ )
90
+ else:
91
+ action = "removed"
92
+ detail = f"architectural tag @{base_rule.name} on '{where}' was removed."
93
+ sig = f"{signature or 'class'}|@{base_rule.name}"
94
+ yield self.emit(
95
+ qualified_name=qn,
96
+ signature=sig,
97
+ message=self.message_for(
98
+ qualified_name=qn,
99
+ rule=base_rule.name,
100
+ member=where,
101
+ action=action,
102
+ )
103
+ or detail,
104
+ location=location,
105
+ )
@@ -0,0 +1,156 @@
1
+ """Rule: a frozen implementation may not change at all.
2
+
3
+ The adapter for the implementation-freeze engine (`code_constraints.lock`,
4
+ "Engine C"). It answers a narrower question than every other rule here — not
5
+ "did the design drift" or "does the code obey its tag", but "did this body
6
+ change at all" — by comparing an AST-derived digest against the approved digest
7
+ recorded in the `locks:` section of `.cdec/rules.yaml`.
8
+
9
+ Two things about this rule are deliberately unlike the others.
10
+
11
+ **Its violations cannot be excepted.** `waivable=False`, so
12
+ `cdec exceptions allow` refuses a lock key and prints the privileged command
13
+ instead. Accepting a change to frozen code is
14
+ `cdec check --automatic-exceptions locks --force`, which rewrites the ledger and
15
+ therefore shows up as a reviewable diff. Route it through the ordinary exception
16
+ list and that reviewability is gone.
17
+
18
+ **It carries its own baseline.** `accept_current_state` records digests for
19
+ newly tagged elements — safe for anyone to run, because without `force` it can
20
+ only ever *add* a lock, never overwrite evidence that a locked body changed.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Iterable
27
+
28
+ from code_constraints.core.model import SourceLocation
29
+ from code_constraints.lint.rules import register
30
+ from code_constraints.lint.rules.base import Rule, RuleContext, RuleSkipped, Violation
31
+
32
+ if TYPE_CHECKING:
33
+ from code_constraints.lock import LockOptions
34
+
35
+ #: Languages with an AST fingerprinter. Freezing a body needs one.
36
+ LOCKABLE_LANGUAGES = ("python", "csharp", "odin", "lua", "julia")
37
+
38
+
39
+ @register("implementation-locks")
40
+ class ImplementationLocks(Rule):
41
+ """Options:
42
+ targets: list[str] of qualified-name globs to freeze WITHOUT a
43
+ tag in the source, e.g. ["orders.pricing.**"].
44
+ include_docstrings: bool — do docstring edits count as implementation
45
+ changes? (default false)
46
+ """
47
+
48
+ supports_auto_accept = True
49
+
50
+ # ---- configuration ----
51
+ def lock_options(self) -> "LockOptions":
52
+ from code_constraints.lock import LockOptions
53
+
54
+ targets = self.options.get("targets") or []
55
+ if not isinstance(targets, list):
56
+ raise RuleSkipped(f"{self.rule_id}: 'targets' must be a list of globs")
57
+ return LockOptions(
58
+ include_docstrings=bool(self.options.get("include_docstrings", False)),
59
+ patterns=[str(t) for t in targets],
60
+ )
61
+
62
+ def _resolve(self, ctx: RuleContext) -> tuple[Path, str, Path]:
63
+ """(source, language, config_dir), or `RuleSkipped` explaining why not.
64
+
65
+ Returns the values rather than just validating them so the caller — and
66
+ the type checker — both see them narrowed.
67
+ """
68
+ if ctx.source is None or not ctx.language:
69
+ raise RuleSkipped("no source tree resolved")
70
+ if ctx.language not in LOCKABLE_LANGUAGES:
71
+ raise RuleSkipped(
72
+ f"{ctx.language} has no AST fingerprinter; implementation locks "
73
+ f"support {', '.join(LOCKABLE_LANGUAGES)}"
74
+ )
75
+ if ctx.config_dir is None:
76
+ raise RuleSkipped("no .cdec/ folder resolved to read the lock ledger from")
77
+ return ctx.source, ctx.language, ctx.config_dir
78
+
79
+ # ---- checking ----
80
+ def check(self, ctx: RuleContext) -> Iterable[Violation]:
81
+ source, language, config_dir = self._resolve(ctx)
82
+ from code_constraints.lock import UnsupportedLockLanguage, check_locks, load_locks
83
+
84
+ options = self.lock_options()
85
+ entries = load_locks(config_dir)
86
+ try:
87
+ report = check_locks(source, language, entries, options)
88
+ except UnsupportedLockLanguage as exc:
89
+ raise RuleSkipped(str(exc)) from exc
90
+
91
+ for violation in report.violations:
92
+ if self.is_ignored(violation.target):
93
+ continue
94
+ yield Violation(
95
+ rule_id=self.rule_id,
96
+ severity=self.severity,
97
+ qualified_name=violation.target,
98
+ message=self.message_for(
99
+ qualified_name=violation.target,
100
+ kind=violation.kind,
101
+ message=violation.message,
102
+ )
103
+ or violation.message,
104
+ location=(
105
+ SourceLocation(file=violation.file, start_line=violation.line, end_line=violation.line)
106
+ if violation.file
107
+ else None
108
+ ),
109
+ key_engine="lock",
110
+ key_rule=violation.kind,
111
+ waivable=False,
112
+ )
113
+
114
+ # ---- baselining ----
115
+ def accept_current_state(self, ctx: RuleContext, *, force: bool = False) -> list[str]:
116
+ """Record digests for locked elements — `--automatic-exceptions locks`.
117
+
118
+ Without `force` this only *adds* entries for newly tagged code, so it is
119
+ safe for anyone to run and can never erase the evidence that a frozen
120
+ implementation changed. `force` is the lead-gated path that accepts a
121
+ drifted body and prunes released entries.
122
+ """
123
+ source, language, config_dir = self._resolve(ctx)
124
+ from code_constraints.lock import UnsupportedLockLanguage, load_locks, update_locks, write_locks
125
+
126
+ options = self.lock_options()
127
+ entries = load_locks(config_dir)
128
+ try:
129
+ updated, result = update_locks(
130
+ source, language, entries, options, force=force
131
+ )
132
+ except UnsupportedLockLanguage as exc:
133
+ raise RuleSkipped(str(exc)) from exc
134
+
135
+ lines: list[str] = []
136
+ for entry in result.added:
137
+ lines.append(f" + locked {entry.target} ({entry.kind}, {entry.digest[:12]})")
138
+ for entry in result.updated:
139
+ lines.append(f" ~ rebased {entry.target} ({entry.kind}, {entry.digest[:12]})")
140
+ for entry in result.removed:
141
+ lines.append(f" - released {entry.target}")
142
+ for blocked in result.blocked:
143
+ lines.append(
144
+ f" ! CHANGED {blocked.target} — left untouched; re-run with --force "
145
+ f"to accept it as the new baseline (a lead's call)."
146
+ )
147
+ for stale in result.stale:
148
+ lines.append(
149
+ f" ! STALE {stale.target} — still locked but its element is gone or "
150
+ f"has lost its tag; restore it, or drop it with --force."
151
+ )
152
+ if result.changed:
153
+ lines.append(f" wrote {len(updated)} lock(s) to {write_locks(config_dir, updated.values())}")
154
+ elif not lines:
155
+ lines.append(" every lock is already recorded; nothing to write.")
156
+ return lines
@@ -0,0 +1,92 @@
1
+ """Rule: enforce allowed dependency directions between architectural layers.
2
+
3
+ Classes declare their layer with the `@layer("name")` tag. This rule takes an
4
+ allowed-direction matrix and flags any reference from a class in layer A to a
5
+ class in layer B when B is not in A's allow-list. Same-layer references are
6
+ always permitted.
7
+
8
+ Pure architectural check (Engine A): it reads tags + the structural reference
9
+ graph (`ctx.outgoing_refs`); it never inspects method bodies.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Iterable
15
+
16
+ from code_constraints.core.model import DiffStatus
17
+ from code_constraints.lint.rules import register
18
+ from code_constraints.lint.rules.base import Rule, RuleContext, Violation
19
+
20
+ _LAYER_RULE_ID = "layer"
21
+
22
+
23
+ def _layer_of(cls) -> str | None:
24
+ """The layer name declared by `@layer("name")`, or None.
25
+
26
+ The first positional arg is the layer name as raw source text, so it may be
27
+ wrapped in quotes (`'domain'` from Python, `"domain"` from C#) — strip them.
28
+ """
29
+ for rule in cls.rules:
30
+ if rule.name == _LAYER_RULE_ID and rule.args:
31
+ return rule.args[0].strip().strip("'\"")
32
+ return None
33
+
34
+
35
+ @register("layer-dependencies")
36
+ class LayerDependencies(Rule):
37
+ """Options:
38
+ allow: mapping of layer -> list of layers it MAY depend on. A class whose
39
+ layer is not a key in `allow` is left unconstrained. An empty list
40
+ means the layer may not depend on any *other* layer.
41
+ """
42
+
43
+ def check(self, ctx: RuleContext) -> Iterable[Violation]:
44
+ allow_raw = self.options.get("allow") or {}
45
+ allow: dict[str, set[str]] = {
46
+ str(k): {str(v) for v in (vals or [])} for k, vals in allow_raw.items()
47
+ }
48
+ if not allow:
49
+ return
50
+
51
+ layer_by_qn: dict[str, str] = {}
52
+ for cls in ctx.project.iter_classes():
53
+ if cls.status == DiffStatus.REMOVED:
54
+ continue
55
+ lyr = _layer_of(cls)
56
+ if lyr is not None:
57
+ layer_by_qn[cls.qualified_name] = lyr
58
+
59
+ for cls in ctx.project.iter_classes():
60
+ if cls.status == DiffStatus.REMOVED:
61
+ continue
62
+ src_qn = cls.qualified_name
63
+ if self.is_ignored(src_qn):
64
+ continue
65
+ src_layer = layer_by_qn.get(src_qn)
66
+ if src_layer is None or src_layer not in allow:
67
+ continue
68
+ allowed = allow[src_layer]
69
+ for tgt_qn in ctx.outgoing_refs.get(src_qn, set()):
70
+ if self.is_ignored(tgt_qn):
71
+ continue
72
+ tgt_layer = layer_by_qn.get(tgt_qn)
73
+ if tgt_layer is None or tgt_layer == src_layer:
74
+ continue
75
+ if tgt_layer in allowed:
76
+ continue
77
+ yield self.emit(
78
+ qualified_name=src_qn,
79
+ signature=f"{src_layer}->{tgt_layer}:{tgt_qn}",
80
+ message=self.message_for(
81
+ qualified_name=src_qn,
82
+ source=src_qn,
83
+ target=tgt_qn,
84
+ source_layer=src_layer,
85
+ target_layer=tgt_layer,
86
+ )
87
+ or (
88
+ f"layer '{src_layer}' may not depend on layer "
89
+ f"'{tgt_layer}' ('{src_qn}' -> '{tgt_qn}')."
90
+ ),
91
+ location=cls.location,
92
+ )