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,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
|