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,179 @@
1
+ """Orchestrate: build RuleContext, dispatch to rules, collect violations.
2
+
3
+ This is the whole of `cdec check`. Every gate the tool offers — configured
4
+ architectural rules, source-tag conformance, implementation locks, the
5
+ reference gate — arrives here as a `Rule`, so there is one run, one report, one
6
+ exit code, and one place to grant an exception.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Iterable
14
+
15
+ from code_constraints.core.associations import resolve_association
16
+ from code_constraints.core.model import DiffStatus, Package, Project
17
+
18
+ from code_constraints.lint.baseline import Baseline
19
+ from code_constraints.lint.report import Report
20
+ from code_constraints.lint.rules.base import Rule, RuleContext, RuleSkipped, Violation
21
+
22
+
23
+ @dataclass
24
+ class SourceContext:
25
+ """Where the code and the config live.
26
+
27
+ Needed only by the rules that adapt an engine which re-reads the source
28
+ (tag conformance, locks) or loads a second model (the reference gate).
29
+ Rules that read the model alone never touch it, which is why it is optional.
30
+ """
31
+
32
+ source: Path | None = None
33
+ language: str = ""
34
+ config_dir: Path | None = None
35
+ reference_path: Path | None = None
36
+
37
+
38
+ def run_checks(
39
+ project: Project,
40
+ rules: Iterable[Rule],
41
+ *,
42
+ has_diff: bool,
43
+ baseline: Baseline | None = None,
44
+ baseline_project: Project | None = None,
45
+ source_context: SourceContext | None = None,
46
+ bypass_locks: bool = False,
47
+ bypass_reason: str = "",
48
+ ) -> Report:
49
+ """Run every rule and return a `Report`.
50
+
51
+ `project` is the annotated `Project` when `has_diff` is True (i.e. the
52
+ output of `diff_projects`); when `has_diff` is False it's a raw parse and
53
+ every element has `DiffStatus.UNCHANGED`.
54
+
55
+ `baseline_project` is the OLD side of the diff (unannotated). Diff-scope
56
+ rules that need the previous element state (e.g. frozen-rules) read it via
57
+ `ctx.baseline_class_by_qn`.
58
+
59
+ `bypass_locks` moves lock violations out of the failing set into
60
+ `Report.bypassed`. They are still collected and still printed under an audit
61
+ banner, and the JSON report says so, so a pipeline can reject a bypassed run
62
+ on a protected branch rather than silently accepting it.
63
+ """
64
+ ctx = _build_context(project, has_diff, baseline_project, source_context)
65
+ raw: list[Violation] = []
66
+ skipped: list[tuple[str, str]] = []
67
+ for rule in rules:
68
+ if rule.scope == "diff" and not has_diff:
69
+ skipped.append((rule.rule_id, "no baseline available; diff-scope rule skipped"))
70
+ continue
71
+ try:
72
+ # Materialised inside the guard: `check` is a generator in most
73
+ # rules, so a RuleSkipped raised in its body only surfaces on
74
+ # iteration.
75
+ raw.extend(rule.check(ctx) or ())
76
+ except RuleSkipped as exc:
77
+ skipped.append((rule.rule_id, str(exc)))
78
+ if baseline is not None:
79
+ kept, suppressed = baseline.filter(raw)
80
+ else:
81
+ kept, suppressed = raw, []
82
+ bypassed: list[Violation] = []
83
+ if bypass_locks:
84
+ held = [v for v in kept if v.key_engine == "lock"]
85
+ if held:
86
+ kept = [v for v in kept if v.key_engine != "lock"]
87
+ bypassed = held
88
+ return Report(
89
+ violations=kept,
90
+ suppressed=suppressed,
91
+ skipped=skipped,
92
+ bypassed=bypassed,
93
+ bypass_reason=bypass_reason if bypassed else "",
94
+ )
95
+
96
+
97
+ def _build_context(
98
+ project: Project,
99
+ has_diff: bool,
100
+ baseline_project: Project | None = None,
101
+ source_context: SourceContext | None = None,
102
+ ) -> RuleContext:
103
+ src = source_context or SourceContext()
104
+ ctx = RuleContext(
105
+ project=project,
106
+ has_diff=has_diff,
107
+ source=src.source,
108
+ language=src.language,
109
+ config_dir=src.config_dir,
110
+ reference_path=src.reference_path,
111
+ )
112
+ # Class index by qualified name.
113
+ for cls in project.iter_classes():
114
+ ctx.class_by_qn[cls.qualified_name] = cls
115
+ if baseline_project is not None:
116
+ for cls in baseline_project.iter_classes():
117
+ ctx.baseline_class_by_qn[cls.qualified_name] = cls
118
+ # Map class -> containing package.
119
+ for pkg in _walk_packages(project.packages):
120
+ for cls in pkg.classes:
121
+ ctx.class_to_package[cls.qualified_name] = pkg.qualified_name
122
+
123
+ # Outgoing references (attribute types + bases that resolve to project classes).
124
+ for cls in project.iter_classes():
125
+ if cls.status == DiffStatus.REMOVED:
126
+ continue
127
+ outgoing: set[str] = set()
128
+ for attr in cls.attributes:
129
+ if attr.status == DiffStatus.REMOVED:
130
+ continue
131
+ target_qn, _ = resolve_association(attr.type, project)
132
+ if target_qn:
133
+ outgoing.add(target_qn)
134
+ # Usage references: method parameter / return types and types used
135
+ # inside method bodies. Mirrors graph_model so the dangling rule and the
136
+ # diagram agree on what "references" a class.
137
+ for op in cls.operations:
138
+ if op.status == DiffStatus.REMOVED:
139
+ continue
140
+ for raw_type in (op.return_type, *(p.type for p in op.parameters)):
141
+ target_qn, _ = resolve_association(raw_type, project)
142
+ if target_qn:
143
+ outgoing.add(target_qn)
144
+ for dep in cls.dependencies:
145
+ target_qn, _ = resolve_association(dep, project)
146
+ if target_qn:
147
+ outgoing.add(target_qn)
148
+ for base in cls.bases:
149
+ if base in ctx.class_by_qn:
150
+ outgoing.add(base)
151
+ continue
152
+ # Fall back to short-name match (mirrors graph_model logic).
153
+ short = base.split(".")[-1]
154
+ for qn in ctx.class_by_qn:
155
+ if qn.split(".")[-1] == short:
156
+ outgoing.add(qn)
157
+ break
158
+ ctx.outgoing_refs[cls.qualified_name] = outgoing
159
+ for tgt in outgoing:
160
+ ctx.incoming_refs.setdefault(tgt, set()).add(cls.qualified_name)
161
+
162
+ # Aggregate to package level.
163
+ for src_qn, tgt_qns in ctx.outgoing_refs.items():
164
+ src_pkg = ctx.class_to_package.get(src_qn)
165
+ if src_pkg is None:
166
+ continue
167
+ bucket = ctx.outgoing_pkg_refs.setdefault(src_pkg, set())
168
+ for tgt_qn in tgt_qns:
169
+ tgt_pkg = ctx.class_to_package.get(tgt_qn)
170
+ if tgt_pkg is None or tgt_pkg == src_pkg:
171
+ continue
172
+ bucket.add(tgt_pkg)
173
+ return ctx
174
+
175
+
176
+ def _walk_packages(packages: list[Package]):
177
+ for pkg in packages:
178
+ yield pkg
179
+ yield from _walk_packages(pkg.sub_packages)
@@ -0,0 +1,108 @@
1
+ """Parse the source and resolve the baseline — the front half of `cdec check`.
2
+
3
+ Extracted so that anything needing the *same* issues `cdec check` would report
4
+ computes them the same way. That matters for the review workflow: a key is only
5
+ useful if the command that grants a waiver sees exactly the issues the command
6
+ that printed the report saw, which means resolving the baseline identically
7
+ (same reference file, same `--base-ref`, same diff).
8
+
9
+ Pure functions raising `PipelineError`; the CLI adapts to typer, other callers
10
+ adapt as they like.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import tempfile
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from code_constraints.core.diff import diff_projects
21
+ from code_constraints.core.model import SUPPORTED_LANGUAGES, Project
22
+ from code_constraints.lint.config import REFERENCE_FILENAME
23
+
24
+ SUPPORTED_LANGS = SUPPORTED_LANGUAGES
25
+
26
+
27
+ class PipelineError(RuntimeError):
28
+ """Raised when the source or the baseline can't be prepared."""
29
+
30
+
31
+ def parse_source(path: Path, lang: str) -> Project:
32
+ if lang == "python":
33
+ from code_constraints.python import parse_project
34
+ elif lang == "csharp":
35
+ from code_constraints.csharp import parse_project
36
+ elif lang == "typescript":
37
+ from code_constraints.typescript import parse_project
38
+ elif lang == "svelte":
39
+ from code_constraints.svelte import parse_project
40
+ elif lang == "odin":
41
+ from code_constraints.odin import parse_project
42
+ elif lang == "lua":
43
+ from code_constraints.lua import parse_project
44
+ elif lang == "julia":
45
+ from code_constraints.julia import parse_project
46
+ else:
47
+ raise PipelineError(f"unsupported language: {lang}")
48
+ return parse_project(path)
49
+
50
+
51
+ def checkout_revision(repo: Any, ref: str, dest: Path) -> Path:
52
+ """Copy the tree at `ref` into `dest` (avoids touching the working tree)."""
53
+ dest.mkdir(parents=True, exist_ok=True)
54
+ commit = repo.commit(ref)
55
+ archive = dest.with_suffix(".tar")
56
+ with archive.open("wb") as fh:
57
+ repo.archive(fh, treeish=commit.hexsha, format="tar")
58
+ shutil.unpack_archive(str(archive), str(dest), format="tar")
59
+ archive.unlink()
60
+ return dest
61
+
62
+
63
+ def resolve_baseline(
64
+ *,
65
+ head_proj: Project,
66
+ lang: str,
67
+ config_dir: Path,
68
+ explicit_reference: Path | None = None,
69
+ explicit_base_ref: str | None = None,
70
+ repo_path: Path = Path("."),
71
+ default_reference: Path | None = None,
72
+ ) -> tuple[Project, bool, Project | None]:
73
+ """Returns (project_for_rules, has_diff, baseline_project).
74
+
75
+ When no baseline is available, returns the head project unchanged with
76
+ has_diff=False and a None baseline, so diff-scope rules get skipped rather
77
+ than silently passing.
78
+ """
79
+ if explicit_base_ref:
80
+ from git import Repo
81
+
82
+ git_repo = Repo(str(Path(repo_path).resolve()))
83
+ with tempfile.TemporaryDirectory(prefix="cdec-check-") as tmp:
84
+ base_dir = checkout_revision(git_repo, explicit_base_ref, Path(tmp) / "base")
85
+ base_proj = parse_source(base_dir, lang)
86
+ try:
87
+ annotated = diff_projects(base_proj, head_proj)
88
+ except ValueError as exc:
89
+ raise PipelineError(str(exc)) from exc
90
+ return annotated, True, base_proj
91
+
92
+ ref_path: Path | None = explicit_reference or default_reference
93
+ if ref_path is None:
94
+ candidate = config_dir / REFERENCE_FILENAME
95
+ if candidate.is_file():
96
+ ref_path = candidate
97
+
98
+ if ref_path and ref_path.is_file():
99
+ from code_constraints.core.model_io import load_model
100
+
101
+ base_proj = load_model(ref_path)
102
+ try:
103
+ annotated = diff_projects(base_proj, head_proj)
104
+ except ValueError as exc:
105
+ raise PipelineError(str(exc)) from exc
106
+ return annotated, True, base_proj
107
+
108
+ return head_proj, False, None
@@ -0,0 +1,151 @@
1
+ """Format the `cdec check` output as human-readable text or JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from code_constraints.lint.rules.base import Severity, Violation
11
+
12
+
13
+ @dataclass
14
+ class Report:
15
+ violations: list[Violation] = field(default_factory=list)
16
+ suppressed: list[Violation] = field(default_factory=list)
17
+ # Rules that couldn't run (e.g. a diff-scope rule with no baseline, or a
18
+ # language with no AST fingerprinter). Reported, never swallowed.
19
+ skipped: list[tuple[str, str]] = field(default_factory=list)
20
+ # Lock violations held back by `--bypass-locks`: reported under an audit
21
+ # banner and flagged in JSON, but not counted as failures.
22
+ bypassed: list[Violation] = field(default_factory=list)
23
+ bypass_reason: str = ""
24
+
25
+ def has_failures(self, fail_on: Severity) -> bool:
26
+ if fail_on == Severity.OFF:
27
+ return False
28
+ if fail_on == Severity.WARNING:
29
+ return any(v.severity in (Severity.ERROR, Severity.WARNING) for v in self.violations)
30
+ # default: error
31
+ return any(v.severity == Severity.ERROR for v in self.violations)
32
+
33
+ def to_human(self) -> str:
34
+ lines: list[str] = []
35
+ if self.bypassed:
36
+ lines.extend(self._bypass_banner())
37
+ if not self.violations:
38
+ lines.append("cdec check: no violations.")
39
+ else:
40
+ lines.extend(self._violation_lines())
41
+ if self.suppressed:
42
+ lines.append(
43
+ f"({len(self.suppressed)} violation(s) silenced by an exception / ignore.)"
44
+ )
45
+ for rule_id, reason in self.skipped:
46
+ lines.append(f"[skipped] {rule_id}: {reason}")
47
+ total_errors = sum(1 for v in self.violations if v.severity == Severity.ERROR)
48
+ total_warnings = sum(1 for v in self.violations if v.severity == Severity.WARNING)
49
+ lines.append(f"Summary: {total_errors} error(s), {total_warnings} warning(s).")
50
+ lines.extend(self._guidance())
51
+ return "\n".join(lines) + "\n"
52
+
53
+ def _violation_lines(self) -> list[str]:
54
+ lines: list[str] = []
55
+ by_rule: dict[str, list[Violation]] = {}
56
+ for v in self.violations:
57
+ by_rule.setdefault(v.rule_id, []).append(v)
58
+ for rule_id in sorted(by_rule):
59
+ group = by_rule[rule_id]
60
+ lines.append(f"[{rule_id}] ({group[0].severity.value})")
61
+ if not group[0].waivable:
62
+ lines.append(
63
+ " NOT EXCEPTABLE: a frozen implementation changes only via "
64
+ "`cdec check --automatic-exceptions locks --force`."
65
+ )
66
+ for v in group:
67
+ loc = ""
68
+ if v.location is not None:
69
+ loc = f" — {v.location.file}:{v.location.start_line}"
70
+ msg_lines = (v.message or "").splitlines() or [""]
71
+ lines.append(f" - [{v.key()}] {v.qualified_name}{loc}: {msg_lines[0]}")
72
+ for cont in msg_lines[1:]:
73
+ lines.append(f" {cont}" if cont else "")
74
+ lines.append("")
75
+ return lines
76
+
77
+ def _bypass_banner(self) -> list[str]:
78
+ lines = [
79
+ "!" * 72,
80
+ "cdec check: LOCKS BYPASSED — frozen implementations were NOT enforced.",
81
+ ]
82
+ if self.bypass_reason:
83
+ lines.append(f" reason: {self.bypass_reason}")
84
+ lines.append(f" {len(self.bypassed)} lock violation(s) suppressed:")
85
+ for v in sorted(self.bypassed, key=lambda x: x.qualified_name):
86
+ lines.append(f" - [{v.key()}] {v.qualified_name}")
87
+ lines.append("!" * 72)
88
+ lines.append("")
89
+ return lines
90
+
91
+ def _guidance(self) -> list[str]:
92
+ """Commented on purpose: this text lands inside `--log-out` files that
93
+ are handed straight back to `cdec exceptions patch`, and an uncommented
94
+ `[ALLOW]` in the guidance would read as a decision."""
95
+ example = next((v.key() for v in self.violations if v.waivable), "")
96
+ if not example:
97
+ return []
98
+ # Quote a key that is actually in this report, so the line can be copied
99
+ # rather than adapted.
100
+ return [
101
+ "# To accept any of these as known-and-allowed, quote its key:\n"
102
+ f"# cdec exceptions allow {example} --reason \"why\"\n"
103
+ "# Or mark them in bulk: add [ALLOW] to a line of this report and\n"
104
+ "# apply it with `cdec exceptions patch --file <report>`."
105
+ ]
106
+
107
+ def to_json(self) -> dict[str, Any]:
108
+ return {
109
+ "violations": [_v_to_json(v) for v in self.violations],
110
+ "suppressed": [_v_to_json(v) for v in self.suppressed],
111
+ "bypassed": [_v_to_json(v) for v in self.bypassed],
112
+ "skipped": [{"rule_id": r, "reason": reason} for r, reason in self.skipped],
113
+ "summary": {
114
+ "errors": sum(1 for v in self.violations if v.severity == Severity.ERROR),
115
+ "warnings": sum(1 for v in self.violations if v.severity == Severity.WARNING),
116
+ "suppressed": len(self.suppressed),
117
+ "skipped": len(self.skipped),
118
+ # Kept as a top-level flag so a pipeline can reject a bypassed
119
+ # run on a protected branch with one JSON lookup.
120
+ "bypassed": bool(self.bypassed),
121
+ "bypass_reason": self.bypass_reason,
122
+ },
123
+ }
124
+
125
+ def write_json(self, path: Path) -> None:
126
+ path.parent.mkdir(parents=True, exist_ok=True)
127
+ with path.open("w", encoding="utf-8") as fh:
128
+ json.dump(self.to_json(), fh, indent=2, sort_keys=True)
129
+
130
+
131
+ def _v_to_json(v: Violation) -> dict[str, Any]:
132
+ out: dict[str, Any] = {
133
+ "key": v.key(),
134
+ "rule_id": v.rule_id,
135
+ "engine": v.key_engine,
136
+ "severity": v.severity.value,
137
+ "qualified_name": v.qualified_name,
138
+ "message": v.message,
139
+ "waivable": v.waivable,
140
+ }
141
+ if v.key_rule:
142
+ out["rule"] = v.key_rule
143
+ if v.signature:
144
+ out["signature"] = v.signature
145
+ if v.location is not None:
146
+ out["location"] = {
147
+ "file": v.location.file,
148
+ "start_line": v.location.start_line,
149
+ "end_line": v.location.end_line,
150
+ }
151
+ return out
@@ -0,0 +1,50 @@
1
+ """Rule registry. Importing this package side-effect-registers every rule."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Type
6
+
7
+ from code_constraints.lint.rules.base import Rule
8
+
9
+ _REGISTRY: dict[str, Type[Rule]] = {}
10
+
11
+
12
+ def register(type_name: str):
13
+ def decorator(cls: Type[Rule]) -> Type[Rule]:
14
+ cls.type_name = type_name
15
+ _REGISTRY[type_name] = cls
16
+ return cls
17
+
18
+ return decorator
19
+
20
+
21
+ def get_rule_class(type_name: str) -> Type[Rule] | None:
22
+ return _REGISTRY.get(type_name)
23
+
24
+
25
+ def known_rule_types() -> list[str]:
26
+ return sorted(_REGISTRY.keys())
27
+
28
+
29
+ # Side-effect imports — every rule module must be imported here so its
30
+ # @register decorator runs.
31
+ from code_constraints.lint.rules import ( # noqa: E402, F401
32
+ no_new_classes,
33
+ no_removed_classes,
34
+ dangling_classes,
35
+ frozen_members,
36
+ frozen_rules,
37
+ forbidden_references,
38
+ forbidden_package_references,
39
+ layer_dependencies,
40
+ subclass_naming,
41
+ cyclic_package_dependencies,
42
+ max_class_fanout,
43
+ # Adapters for the three decoupled engines that used to be separate
44
+ # commands. They import their engine lazily and translate its results into
45
+ # `Violation`s, so the engines stay independent packages while the user sees
46
+ # one file of rules and one `cdec check`.
47
+ tag_conformance,
48
+ implementation_locks,
49
+ reference_architecture,
50
+ )
@@ -0,0 +1,200 @@
1
+ """Rule base class, Violation dataclass, RuleContext."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Any, Iterable
10
+
11
+ from code_constraints.core.keys import make_key
12
+ from code_constraints.core.model import Class, DiffStatus, Project, SourceLocation
13
+
14
+
15
+ class RuleSkipped(RuntimeError):
16
+ """Raised by a rule that cannot run at all in this project.
17
+
18
+ Recorded in `Report.skipped` rather than swallowed: a rule that quietly
19
+ checks nothing looks exactly like a rule that passed, and that is how a
20
+ guardrail stops guarding without anybody noticing.
21
+ """
22
+
23
+
24
+ class Severity(str, Enum):
25
+ ERROR = "error"
26
+ WARNING = "warning"
27
+ OFF = "off"
28
+
29
+
30
+ @dataclass
31
+ class Violation:
32
+ rule_id: str
33
+ severity: Severity
34
+ qualified_name: str
35
+ message: str
36
+ location: SourceLocation | None = None
37
+ # `signature` is used by the exception fingerprint so that frozen-members
38
+ # violations on the same class can be distinguished by member.
39
+ signature: str | None = None
40
+ # --- key identity -------------------------------------------------------
41
+ # A key names the *issue*, not the config entry that surfaced it. Rules that
42
+ # adapt one of the decoupled engines (tag conformance, locks, the reference
43
+ # gate) therefore key their violations under that engine's own identity, so
44
+ # renaming a `rules.yaml` entry never invalidates a granted exception and a
45
+ # key recorded before the CLI was unified still resolves. Native rules leave
46
+ # these unset and key as `check`/`rule_id`, exactly as they always have.
47
+ key_engine: str = "check"
48
+ key_rule: str | None = None
49
+ # False for issues that must not be accepted as exceptions — locks, whose
50
+ # escape hatch is a privileged re-baseline instead.
51
+ waivable: bool = True
52
+
53
+ def fingerprint(self) -> tuple[str, str, str]:
54
+ return (self.rule_id, self.qualified_name, self.signature or "")
55
+
56
+ def key(self) -> str:
57
+ """Stable review key. Derived from the issue identity, so it never moves
58
+ when the file does — see `code_constraints.core.keys`."""
59
+ return make_key(
60
+ self.key_engine,
61
+ self.key_rule or self.rule_id,
62
+ self.qualified_name,
63
+ self.signature or "",
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class RuleContext:
69
+ """Per-run context, built once and passed to every rule.
70
+
71
+ The annotated project carries DiffStatus values when a baseline was used;
72
+ `has_diff` is False when running in snapshot-only mode (no baseline).
73
+ """
74
+ project: Project
75
+ has_diff: bool
76
+ # --- source-level context ----------------------------------------------
77
+ # Most rules read only the model. The rules that adapt an engine which
78
+ # re-parses bodies (tag conformance, locks) or loads another model (the
79
+ # reference gate) need to know where the code and the config live. The
80
+ # engines themselves stay independent packages: these rules are adapters
81
+ # that import them lazily and translate their results into `Violation`s.
82
+ source: Path | None = None
83
+ language: str = ""
84
+ config_dir: Path | None = None
85
+ reference_path: Path | None = None
86
+ # Cached: qualified_name -> set of qualified names that reference it
87
+ # (attribute type or base class).
88
+ incoming_refs: dict[str, set[str]] = field(default_factory=dict)
89
+ # qualified_name -> set of qualified names this class references.
90
+ outgoing_refs: dict[str, set[str]] = field(default_factory=dict)
91
+ # package qualified_name -> set of package qualified names it references.
92
+ outgoing_pkg_refs: dict[str, set[str]] = field(default_factory=dict)
93
+ # class qualified_name -> Class
94
+ class_by_qn: dict[str, Class] = field(default_factory=dict)
95
+ # class qualified_name -> containing package qualified_name
96
+ class_to_package: dict[str, str] = field(default_factory=dict)
97
+ # Baseline (OLD side) classes by qualified name; populated only when a
98
+ # baseline project was supplied. Used by diff-scope rules that need the
99
+ # *previous* element state (e.g. frozen-rules), since the annotated
100
+ # `project` carries the NEW rule lists, not the old ones.
101
+ baseline_class_by_qn: dict[str, Class] = field(default_factory=dict)
102
+
103
+
104
+ def match_any_glob(name: str, patterns: Iterable[str]) -> bool:
105
+ """Match `name` against any of the patterns. Patterns use shell glob
106
+ semantics with `.` treated as a normal character (so `foo.bar.*` matches
107
+ `foo.bar.baz` and `foo.**` matches every descendant)."""
108
+ for p in patterns:
109
+ # Treat `**` as matching across dot-separated segments. `fnmatch` already
110
+ # treats `*` as any char including `.`, so `foo.**` collapses to
111
+ # `foo.*` for matching purposes — which is what we want.
112
+ pat = p.replace("**", "*")
113
+ if fnmatch.fnmatchcase(name, pat):
114
+ return True
115
+ return False
116
+
117
+
118
+ class Rule:
119
+ """Subclasses are instantiated once per `rules.yaml` entry."""
120
+
121
+ type_name: str = "" # set by @register
122
+ #: Scope used when the entry doesn't say. Rules that compare against a
123
+ #: baseline override this to "diff".
124
+ default_scope: str = "snapshot"
125
+ #: True when the rule can record the current state as the new approved
126
+ #: baseline via `accept_current_state` (locks, the reference gate).
127
+ supports_auto_accept: bool = False
128
+
129
+ def __init__(
130
+ self,
131
+ rule_id: str,
132
+ severity: Severity,
133
+ scope: str,
134
+ message: str,
135
+ ignore: list[str],
136
+ options: dict[str, Any],
137
+ ) -> None:
138
+ self.rule_id = rule_id
139
+ self.severity = severity
140
+ self.scope = scope # "diff" | "snapshot"
141
+ self.message_template = message
142
+ self.ignore = list(ignore or [])
143
+ self.options = options
144
+
145
+ # ---- helpers shared by subclasses ----
146
+ def is_ignored(self, qualified_name: str) -> bool:
147
+ return match_any_glob(qualified_name, self.ignore)
148
+
149
+ def message_for(self, **fmt: Any) -> str:
150
+ if not self.message_template:
151
+ return ""
152
+ try:
153
+ return self.message_template.format(**fmt)
154
+ except (KeyError, IndexError):
155
+ return self.message_template
156
+
157
+ def emit(
158
+ self,
159
+ qualified_name: str,
160
+ message: str,
161
+ location: SourceLocation | None = None,
162
+ signature: str | None = None,
163
+ ) -> Violation:
164
+ return Violation(
165
+ rule_id=self.rule_id,
166
+ severity=self.severity,
167
+ qualified_name=qualified_name,
168
+ message=message or self.message_template,
169
+ location=location,
170
+ signature=signature,
171
+ )
172
+
173
+ # ---- override hooks ----
174
+ def check(self, ctx: RuleContext) -> Iterable[Violation]:
175
+ raise NotImplementedError
176
+
177
+ def accept_current_state(self, ctx: RuleContext, *, force: bool = False) -> list[str]:
178
+ """Record the code as it stands now as this rule's approved baseline.
179
+
180
+ Called by `cdec check --automatic-exceptions`. Rules whose issues are
181
+ grandfathered through the `exceptions:` list don't implement this — it
182
+ exists for the two rules that carry a baseline of their own: the lock
183
+ ledger and the reference snapshot. Returns lines describing what
184
+ changed, for the command to print.
185
+ """
186
+ return []
187
+
188
+
189
+ def changed_classes(ctx: RuleContext, only_status: DiffStatus | None = None) -> Iterable[Class]:
190
+ """Yield classes whose status matches `only_status`; or all non-unchanged
191
+ classes when `only_status` is None. Skips the iteration entirely when
192
+ the context isn't from a diff."""
193
+ if not ctx.has_diff:
194
+ return
195
+ for cls in ctx.project.iter_classes():
196
+ if only_status is None:
197
+ if cls.status != DiffStatus.UNCHANGED:
198
+ yield cls
199
+ elif cls.status == only_status:
200
+ yield cls