code-constraints 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_constraints/__init__.py +1 -0
- code_constraints/cli/__init__.py +0 -0
- code_constraints/cli/__main__.py +1555 -0
- code_constraints/cli/_assets/agents/cdec-architect.md +468 -0
- code_constraints/cli/_assets/agents/oop-refactor-architect.md +317 -0
- code_constraints/cli/_assets/shims/csharp/CodeConstraintsRules.cs +94 -0
- code_constraints/cli/_assets/shims/julia/CdecRules.jl +129 -0
- code_constraints/cli/_assets/shims/lua/cdec_rules.lua +92 -0
- code_constraints/cli/_assets/shims/odin/cdec_rules.odin +67 -0
- code_constraints/cli/_assets/shims/python/cdec_rules.py +94 -0
- code_constraints/cli/_assets/skills/cdec-architecture-loop/SKILL.md +152 -0
- code_constraints/cli/depstamp.py +118 -0
- code_constraints/cli/detect.py +77 -0
- code_constraints/cli/interactive.py +304 -0
- code_constraints/cli/scaffold.py +602 -0
- code_constraints/cli/update.py +157 -0
- code_constraints/core/__init__.py +41 -0
- code_constraints/core/annotations.py +217 -0
- code_constraints/core/associations.py +134 -0
- code_constraints/core/diff.py +302 -0
- code_constraints/core/editor_io.py +280 -0
- code_constraints/core/graph_model.py +681 -0
- code_constraints/core/keys.py +105 -0
- code_constraints/core/model.py +294 -0
- code_constraints/core/model_io.py +65 -0
- code_constraints/core/receivers.py +34 -0
- code_constraints/core/rules.py +177 -0
- code_constraints/core/rulesdoc.py +208 -0
- code_constraints/core/tags.py +114 -0
- code_constraints/core/ts_fingerprint.py +88 -0
- code_constraints/core/xmi_reader.py +358 -0
- code_constraints/core/xmi_writer.py +373 -0
- code_constraints/csharp/__init__.py +3 -0
- code_constraints/csharp/activity.py +250 -0
- code_constraints/csharp/conformance.py +331 -0
- code_constraints/csharp/fingerprint.py +274 -0
- code_constraints/csharp/parser.py +436 -0
- code_constraints/csharp/rules_extract.py +78 -0
- code_constraints/csharp/sequence.py +295 -0
- code_constraints/enforce/__init__.py +15 -0
- code_constraints/enforce/engine.py +122 -0
- code_constraints/enforce/model.py +74 -0
- code_constraints/julia/__init__.py +5 -0
- code_constraints/julia/conformance.py +282 -0
- code_constraints/julia/fingerprint.py +226 -0
- code_constraints/julia/parser.py +523 -0
- code_constraints/julia/rules_extract.py +216 -0
- code_constraints/lint/__init__.py +10 -0
- code_constraints/lint/baseline.py +96 -0
- code_constraints/lint/config.py +239 -0
- code_constraints/lint/engine.py +179 -0
- code_constraints/lint/pipeline.py +108 -0
- code_constraints/lint/report.py +151 -0
- code_constraints/lint/rules/__init__.py +50 -0
- code_constraints/lint/rules/base.py +200 -0
- code_constraints/lint/rules/cyclic_package_dependencies.py +69 -0
- code_constraints/lint/rules/dangling_classes.py +98 -0
- code_constraints/lint/rules/forbidden_package_references.py +47 -0
- code_constraints/lint/rules/forbidden_references.py +48 -0
- code_constraints/lint/rules/frozen_members.py +67 -0
- code_constraints/lint/rules/frozen_rules.py +105 -0
- code_constraints/lint/rules/implementation_locks.py +156 -0
- code_constraints/lint/rules/layer_dependencies.py +92 -0
- code_constraints/lint/rules/max_class_fanout.py +41 -0
- code_constraints/lint/rules/no_new_classes.py +27 -0
- code_constraints/lint/rules/no_removed_classes.py +27 -0
- code_constraints/lint/rules/reference_architecture.py +111 -0
- code_constraints/lint/rules/subclass_naming.py +71 -0
- code_constraints/lint/rules/tag_conformance.py +76 -0
- code_constraints/lock/__init__.py +73 -0
- code_constraints/lock/engine.py +395 -0
- code_constraints/lock/model.py +235 -0
- code_constraints/lock/store.py +144 -0
- code_constraints/lua/__init__.py +5 -0
- code_constraints/lua/conformance.py +239 -0
- code_constraints/lua/fingerprint.py +252 -0
- code_constraints/lua/parser.py +500 -0
- code_constraints/lua/rules_extract.py +55 -0
- code_constraints/mcp/__init__.py +20 -0
- code_constraints/mcp/__main__.py +73 -0
- code_constraints/mcp/server.py +1203 -0
- code_constraints/odin/__init__.py +5 -0
- code_constraints/odin/conformance.py +244 -0
- code_constraints/odin/fingerprint.py +159 -0
- code_constraints/odin/parser.py +471 -0
- code_constraints/odin/rules_extract.py +38 -0
- code_constraints/python/__init__.py +3 -0
- code_constraints/python/activity.py +278 -0
- code_constraints/python/conformance.py +249 -0
- code_constraints/python/fingerprint.py +231 -0
- code_constraints/python/parser.py +330 -0
- code_constraints/python/rules_extract.py +83 -0
- code_constraints/python/sequence.py +257 -0
- code_constraints/reference/__init__.py +15 -0
- code_constraints/reference/compare.py +356 -0
- code_constraints/reference/report.py +38 -0
- code_constraints/svelte/__init__.py +3 -0
- code_constraints/svelte/parser.py +523 -0
- code_constraints/typescript/__init__.py +3 -0
- code_constraints/typescript/parser.py +590 -0
- code_constraints/waivers/__init__.py +89 -0
- code_constraints/waivers/collect.py +167 -0
- code_constraints/waivers/model.py +90 -0
- code_constraints/waivers/ops.py +150 -0
- code_constraints/waivers/review.py +156 -0
- code_constraints/waivers/store.py +300 -0
- code_constraints/web/__init__.py +0 -0
- code_constraints/web/_static/assets/index-3ivBsYY4.css +1 -0
- code_constraints/web/_static/assets/index-BTzTqGFp.js +9 -0
- code_constraints/web/_static/index.html +13 -0
- code_constraints/web/app.py +1076 -0
- code_constraints-0.1.0.dist-info/METADATA +663 -0
- code_constraints-0.1.0.dist-info/RECORD +116 -0
- code_constraints-0.1.0.dist-info/WHEEL +4 -0
- code_constraints-0.1.0.dist-info/entry_points.txt +3 -0
- code_constraints-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
)
|