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,216 @@
|
|
|
1
|
+
"""Recognise architectural-rule macros on Julia declarations.
|
|
2
|
+
|
|
3
|
+
Shared by the UML parser, the `cdec enforce` conformance analyzer and the
|
|
4
|
+
`cdec lock` fingerprinter, so "what counts as a rule" stays identical across all
|
|
5
|
+
three — the same contract the Python and C# extractors hold. A macro counts as a
|
|
6
|
+
rule only when the file brings the shim module into scope (`using CdecRules` /
|
|
7
|
+
`import CdecRules`), so a user's own `@sealed` is never misread.
|
|
8
|
+
|
|
9
|
+
Two shapes worth knowing, both of which this module folds into one
|
|
10
|
+
`(rules, definition)` pair via `unwrap_macros`:
|
|
11
|
+
|
|
12
|
+
1. **Stacked tags nest.** Real Julia parses `@sealed @layer "domain" struct X end`
|
|
13
|
+
as `@sealed(@layer("domain", struct X end))` — one argument, itself a
|
|
14
|
+
macrocall. tree-sitter instead emits the inner macrocall and the struct as
|
|
15
|
+
*siblings* inside the outer `macro_argument_list`. `unwrap_macros` handles
|
|
16
|
+
both by scanning the argument list for nested macrocalls and for the
|
|
17
|
+
definition, then recursing.
|
|
18
|
+
|
|
19
|
+
2. **Arguments are space-separated, not parenthesised.** `@locked reason="why"
|
|
20
|
+
function f() end` puts each keyword in its own `assignment` node alongside the
|
|
21
|
+
definition. That collides with Julia's short-form function syntax, which is
|
|
22
|
+
*also* an `assignment` (`f(x::T) = x`); the two are told apart by their
|
|
23
|
+
left-hand side (`identifier` for a keyword, `call_expression` for a
|
|
24
|
+
definition). `@layer("domain")` — the parenthesised form — is a syntax error
|
|
25
|
+
in Julia, but the grammar accepts it, so `_macro_args` reads an
|
|
26
|
+
`argument_list` too rather than silently dropping the tag.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from tree_sitter import Node
|
|
32
|
+
|
|
33
|
+
from code_constraints.core.model import RuleAnnotation
|
|
34
|
+
from code_constraints.core.rules import JULIA_SHIM_MODULES, by_julia_name
|
|
35
|
+
|
|
36
|
+
# Node types that can be the definition a tag decorates.
|
|
37
|
+
DEFINITION_TYPES = frozenset(
|
|
38
|
+
{
|
|
39
|
+
"struct_definition",
|
|
40
|
+
"abstract_definition",
|
|
41
|
+
"primitive_definition",
|
|
42
|
+
"function_definition",
|
|
43
|
+
"assignment", # short-form function definition: `f(x::T) = ...`
|
|
44
|
+
"const_statement",
|
|
45
|
+
"macro_definition",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def using_has_shim(root: Node, source: bytes) -> bool:
|
|
51
|
+
"""True when the file brings the shim module into scope."""
|
|
52
|
+
stack = [root]
|
|
53
|
+
while stack:
|
|
54
|
+
node = stack.pop()
|
|
55
|
+
if node.type in ("using_statement", "import_statement"):
|
|
56
|
+
text = _text(node, source)
|
|
57
|
+
if any(mod in text for mod in JULIA_SHIM_MODULES):
|
|
58
|
+
return True
|
|
59
|
+
stack.extend(node.children)
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def unwrap_macros(node: Node, source: bytes, shim_in_scope: bool) -> tuple[
|
|
64
|
+
list[RuleAnnotation], Node | None
|
|
65
|
+
]:
|
|
66
|
+
"""Peel every macro wrapping `node` and return its rules + the definition.
|
|
67
|
+
|
|
68
|
+
For a node that isn't a macrocall this is `([], node)`. Macros that aren't in
|
|
69
|
+
the rule catalog are peeled too — their arguments still contain the
|
|
70
|
+
definition, so an unrelated `@inline`/`Base.@kwdef` wrapper doesn't hide the
|
|
71
|
+
struct from the parser — but they contribute no `RuleAnnotation`.
|
|
72
|
+
"""
|
|
73
|
+
if node.type != "macrocall_expression":
|
|
74
|
+
return [], node
|
|
75
|
+
|
|
76
|
+
rules: list[RuleAnnotation] = []
|
|
77
|
+
definition: Node | None = None
|
|
78
|
+
pending: list[Node] = [node]
|
|
79
|
+
|
|
80
|
+
while pending:
|
|
81
|
+
current = pending.pop(0)
|
|
82
|
+
if current.type != "macrocall_expression":
|
|
83
|
+
continue
|
|
84
|
+
spec_id = _macro_rule_id(current, source, shim_in_scope)
|
|
85
|
+
args, kwargs, nested, found = _macro_args(current, source)
|
|
86
|
+
if spec_id is not None:
|
|
87
|
+
rules.append(RuleAnnotation(name=spec_id, args=args, kwargs=kwargs))
|
|
88
|
+
if found is not None and definition is None:
|
|
89
|
+
definition = found
|
|
90
|
+
pending.extend(nested)
|
|
91
|
+
|
|
92
|
+
if definition is not None and definition.type == "macrocall_expression":
|
|
93
|
+
inner_rules, inner_def = unwrap_macros(definition, source, shim_in_scope)
|
|
94
|
+
rules.extend(inner_rules)
|
|
95
|
+
definition = inner_def
|
|
96
|
+
|
|
97
|
+
return rules, definition
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def extract_rules(node: Node, source: bytes, shim_in_scope: bool) -> list[RuleAnnotation]:
|
|
101
|
+
"""Just the rules from the macros wrapping `node`."""
|
|
102
|
+
rules, _ = unwrap_macros(node, source, shim_in_scope)
|
|
103
|
+
return rules
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def foreign_macros(node: Node, source: bytes, shim_in_scope: bool) -> list[str]:
|
|
107
|
+
"""Text of every non-catalog macro applied to `node`, sorted.
|
|
108
|
+
|
|
109
|
+
The fingerprinter digests the *unwrapped* definition, because the grammar
|
|
110
|
+
flattens stacked macros into siblings and there is no way to peel one tag
|
|
111
|
+
while re-serialising the rest of the chain around it. Folding this list into
|
|
112
|
+
the digest restores what unwrapping would otherwise lose: adding or removing
|
|
113
|
+
an `@inline` is a real change, while adding or removing `@locked` is not.
|
|
114
|
+
"""
|
|
115
|
+
out: list[str] = []
|
|
116
|
+
pending = [node]
|
|
117
|
+
while pending:
|
|
118
|
+
current = pending.pop(0)
|
|
119
|
+
if current.type != "macrocall_expression":
|
|
120
|
+
continue
|
|
121
|
+
ident = next((c for c in current.children if c.type == "macro_identifier"), None)
|
|
122
|
+
if ident is not None and _macro_rule_id(current, source, shim_in_scope) is None:
|
|
123
|
+
prefix = (
|
|
124
|
+
_text(current.children[0], source) + "."
|
|
125
|
+
if current.children and current.children[0].type == "identifier"
|
|
126
|
+
else ""
|
|
127
|
+
)
|
|
128
|
+
out.append(prefix + _text(ident, source))
|
|
129
|
+
_args, _kwargs, nested, found = _macro_args(current, source)
|
|
130
|
+
pending.extend(nested)
|
|
131
|
+
if found is not None and found.type == "macrocall_expression":
|
|
132
|
+
pending.append(found)
|
|
133
|
+
return sorted(out)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def macro_is_rule(node: Node, source: bytes, shim_in_scope: bool, rule_id: str) -> bool:
|
|
137
|
+
"""True when `node` is a `macrocall_expression` for the given catalog rule.
|
|
138
|
+
|
|
139
|
+
The fingerprinter needs the node-to-tag mapping that `extract_rules` loses,
|
|
140
|
+
to drop the lock tag from a digest without dropping the definition it wraps.
|
|
141
|
+
"""
|
|
142
|
+
if node.type != "macrocall_expression":
|
|
143
|
+
return False
|
|
144
|
+
return _macro_rule_id(node, source, shim_in_scope) == rule_id
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _macro_rule_id(node: Node, source: bytes, shim_in_scope: bool) -> str | None:
|
|
148
|
+
if not shim_in_scope:
|
|
149
|
+
return None
|
|
150
|
+
ident = next((c for c in node.children if c.type == "macro_identifier"), None)
|
|
151
|
+
if ident is None:
|
|
152
|
+
return None
|
|
153
|
+
# A qualified call (`Base.@kwdef`) puts an identifier + `.` before the macro
|
|
154
|
+
# identifier; those are never our tags.
|
|
155
|
+
if node.children and node.children[0].type == "identifier":
|
|
156
|
+
return None
|
|
157
|
+
spec = by_julia_name(_text(ident, source))
|
|
158
|
+
return spec.id if spec else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _macro_args(
|
|
162
|
+
node: Node, source: bytes
|
|
163
|
+
) -> tuple[list[str], dict[str, str], list[Node], Node | None]:
|
|
164
|
+
"""Split a macrocall's arguments into positional / keyword / nested macros /
|
|
165
|
+
the decorated definition."""
|
|
166
|
+
args: list[str] = []
|
|
167
|
+
kwargs: dict[str, str] = {}
|
|
168
|
+
nested: list[Node] = []
|
|
169
|
+
definition: Node | None = None
|
|
170
|
+
|
|
171
|
+
arglist = next(
|
|
172
|
+
(
|
|
173
|
+
c
|
|
174
|
+
for c in node.children
|
|
175
|
+
if c.type in ("macro_argument_list", "argument_list")
|
|
176
|
+
),
|
|
177
|
+
None,
|
|
178
|
+
)
|
|
179
|
+
if arglist is None:
|
|
180
|
+
return args, kwargs, nested, definition
|
|
181
|
+
|
|
182
|
+
for child in arglist.named_children:
|
|
183
|
+
if child.type == "macrocall_expression":
|
|
184
|
+
nested.append(child)
|
|
185
|
+
elif child.type in ("assignment", "named_argument") and _is_keyword(child):
|
|
186
|
+
name, value = _keyword_parts(child, source)
|
|
187
|
+
if name:
|
|
188
|
+
kwargs[name] = value
|
|
189
|
+
elif child.type in DEFINITION_TYPES:
|
|
190
|
+
if definition is None:
|
|
191
|
+
definition = child
|
|
192
|
+
else:
|
|
193
|
+
args.append(_text(child, source))
|
|
194
|
+
|
|
195
|
+
return args, kwargs, nested, definition
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _is_keyword(node: Node) -> bool:
|
|
199
|
+
"""`reason="why"` is a keyword argument; `f(x::T) = x` is a definition."""
|
|
200
|
+
if node.type == "named_argument":
|
|
201
|
+
return True
|
|
202
|
+
first = node.named_children[0] if node.named_children else None
|
|
203
|
+
return first is not None and first.type == "identifier"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _keyword_parts(node: Node, source: bytes) -> tuple[str, str]:
|
|
207
|
+
named = node.named_children
|
|
208
|
+
if len(named) < 2:
|
|
209
|
+
return "", ""
|
|
210
|
+
return _text(named[0], source), _text(named[-1], source)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _text(node: Node | None, source: bytes) -> str:
|
|
214
|
+
if node is None:
|
|
215
|
+
return ""
|
|
216
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Architectural-lint engine for `cdec check`.
|
|
2
|
+
|
|
3
|
+
Reads rule definitions from a project-root `.cdec/` folder, runs them against
|
|
4
|
+
the current `Project` (optionally diffed against a reference), and emits a
|
|
5
|
+
report. Designed to gate CI on rule violations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from code_constraints.lint.engine import run_checks # noqa: F401
|
|
9
|
+
from code_constraints.lint.report import Report # noqa: F401
|
|
10
|
+
from code_constraints.lint.rules.base import Severity, Violation # noqa: F401
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The rule engine's view of the `exceptions:` ledger.
|
|
2
|
+
|
|
3
|
+
The file itself is owned by `code_constraints.waivers.store`, which is engine-
|
|
4
|
+
agnostic; this module is the thin adapter that turns `Violation`s into lookups
|
|
5
|
+
against it. Keeping the file format in one place is what lets an exception
|
|
6
|
+
granted from a reviewed report and one recorded by
|
|
7
|
+
`cdec check --automatic-exceptions` land in the same list.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterable
|
|
15
|
+
|
|
16
|
+
from code_constraints.lint.rules.base import Violation
|
|
17
|
+
from code_constraints.waivers.store import (
|
|
18
|
+
Waiver,
|
|
19
|
+
WaiverStore,
|
|
20
|
+
load_waivers,
|
|
21
|
+
now_stamp,
|
|
22
|
+
save_waivers,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Baseline:
|
|
28
|
+
store: WaiverStore = field(default_factory=WaiverStore)
|
|
29
|
+
|
|
30
|
+
def contains(self, v: Violation) -> bool:
|
|
31
|
+
# Locks are never exempted: `Violation.waivable` is False for them, and
|
|
32
|
+
# honouring an `exceptions:` entry here would be a silent back door
|
|
33
|
+
# around the privileged re-baseline.
|
|
34
|
+
return v.waivable and self.store.has(v.key())
|
|
35
|
+
|
|
36
|
+
def filter(
|
|
37
|
+
self, violations: list[Violation]
|
|
38
|
+
) -> tuple[list[Violation], list[Violation]]:
|
|
39
|
+
kept: list[Violation] = []
|
|
40
|
+
suppressed: list[Violation] = []
|
|
41
|
+
for v in violations:
|
|
42
|
+
(suppressed if self.contains(v) else kept).append(v)
|
|
43
|
+
return kept, suppressed
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_baseline(config_dir: Path) -> Baseline:
|
|
47
|
+
return Baseline(store=load_waivers(config_dir))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_baseline(config_dir: Path, violations: Iterable[Violation]) -> Path:
|
|
51
|
+
"""Grandfather `violations` as the accepted set — `--automatic-exceptions`.
|
|
52
|
+
|
|
53
|
+
Every engine's exceptions are rewritten together, because the violations
|
|
54
|
+
handed in come from one run of every rule. Non-waivable violations (locks)
|
|
55
|
+
are dropped rather than recorded: accepting one of those is a privileged
|
|
56
|
+
re-baseline, never an exception. Reason / author / date already recorded for
|
|
57
|
+
an issue are carried over, so re-running never erases why something was
|
|
58
|
+
accepted. Returns the file written.
|
|
59
|
+
"""
|
|
60
|
+
store = load_waivers(config_dir)
|
|
61
|
+
stamp = now_stamp()
|
|
62
|
+
store.replace_all(
|
|
63
|
+
[
|
|
64
|
+
Waiver(
|
|
65
|
+
engine=v.key_engine,
|
|
66
|
+
rule=v.key_rule or v.rule_id,
|
|
67
|
+
qualified_name=v.qualified_name,
|
|
68
|
+
detail=v.signature or "",
|
|
69
|
+
reason=_existing_reason(store, v),
|
|
70
|
+
added=_existing_added(store, v) or stamp,
|
|
71
|
+
added_by=_existing_actor(store, v),
|
|
72
|
+
)
|
|
73
|
+
for v in violations
|
|
74
|
+
if v.waivable
|
|
75
|
+
]
|
|
76
|
+
)
|
|
77
|
+
return save_waivers(config_dir, store)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _existing(store: WaiverStore, v: Violation) -> Waiver | None:
|
|
81
|
+
return store.get(v.key())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _existing_reason(store: WaiverStore, v: Violation) -> str:
|
|
85
|
+
existing = _existing(store, v)
|
|
86
|
+
return existing.reason if existing else ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _existing_added(store: WaiverStore, v: Violation) -> str:
|
|
90
|
+
existing = _existing(store, v)
|
|
91
|
+
return existing.added if existing else ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _existing_actor(store: WaiverStore, v: Violation) -> str:
|
|
95
|
+
existing = _existing(store, v)
|
|
96
|
+
return existing.added_by if existing else ""
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Load and validate `.cdec/rules.yaml` — project settings and rules.
|
|
2
|
+
|
|
3
|
+
One file holds everything: the settings that say *what* to check, the `rules:`
|
|
4
|
+
list that says *which laws apply*, and (in the tool-managed tail, see
|
|
5
|
+
`code_constraints.core.rulesdoc`) the exceptions granted and the digests of
|
|
6
|
+
frozen implementations.
|
|
7
|
+
|
|
8
|
+
`.cdec/config.yaml` is the legacy home of the settings. It is still read when it
|
|
9
|
+
exists, so a project scaffolded by an older `cdec init` keeps working, but
|
|
10
|
+
`rules.yaml` wins key by key and `cdec init --migrate` folds the old file in.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from code_constraints.core.model import SUPPORTED_LANGUAGES
|
|
22
|
+
from code_constraints.core.rulesdoc import RULES_FILENAME, RulesFileError, load_document
|
|
23
|
+
from code_constraints.lint.rules import get_rule_class, known_rule_types
|
|
24
|
+
from code_constraints.lint.rules.base import Rule, Severity
|
|
25
|
+
|
|
26
|
+
# `RULES_FILENAME` is re-exported: every consumer already imports the rest of the
|
|
27
|
+
# `.cdec/` vocabulary from here, and splitting the filename off would make them
|
|
28
|
+
# import two modules to name one folder.
|
|
29
|
+
__all__ = [
|
|
30
|
+
"BASELINE_FILENAME",
|
|
31
|
+
"CONFIG_FILENAME",
|
|
32
|
+
"ConfigError",
|
|
33
|
+
"LOCKS_FILENAME",
|
|
34
|
+
"LoadedRules",
|
|
35
|
+
"ProjectConfig",
|
|
36
|
+
"REFERENCE_FILENAME",
|
|
37
|
+
"RULES_FILENAME",
|
|
38
|
+
"legacy_files",
|
|
39
|
+
"load_project_config",
|
|
40
|
+
"load_rules",
|
|
41
|
+
"rules_path",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
CONFIG_FILENAME = "config.yaml" # legacy; superseded by rules.yaml
|
|
45
|
+
REFERENCE_FILENAME = "reference.xmi"
|
|
46
|
+
# Legacy ledgers, folded into rules.yaml by `cdec init --migrate`. Still read
|
|
47
|
+
# when present so an existing project doesn't break on upgrade.
|
|
48
|
+
BASELINE_FILENAME = "baseline.yaml"
|
|
49
|
+
LOCKS_FILENAME = "locks.yaml"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ConfigError(ValueError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ProjectConfig:
|
|
58
|
+
"""The `what and where` half of `.cdec/rules.yaml`."""
|
|
59
|
+
|
|
60
|
+
language: str
|
|
61
|
+
source: Path
|
|
62
|
+
config_dir: Path = Path(".cdec")
|
|
63
|
+
reference: Path | None = None
|
|
64
|
+
json_out: Path | None = None
|
|
65
|
+
log_out: Path | None = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def rules_path(self) -> Path:
|
|
69
|
+
return self.config_dir / RULES_FILENAME
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def reference_path(self) -> Path:
|
|
73
|
+
return self.reference or (self.config_dir / REFERENCE_FILENAME)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class LoadedRules:
|
|
78
|
+
rules: list[Rule] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
def of_type(self, type_name: str) -> list[Rule]:
|
|
81
|
+
return [r for r in self.rules if r.type_name == type_name]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def rules_path(config_dir: Path) -> Path:
|
|
85
|
+
return config_dir / RULES_FILENAME
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def legacy_files(config_dir: Path) -> list[Path]:
|
|
89
|
+
"""Legacy per-concern files present in `config_dir`, in migration order."""
|
|
90
|
+
return [
|
|
91
|
+
path
|
|
92
|
+
for path in (
|
|
93
|
+
config_dir / CONFIG_FILENAME,
|
|
94
|
+
config_dir / BASELINE_FILENAME,
|
|
95
|
+
config_dir / LOCKS_FILENAME,
|
|
96
|
+
)
|
|
97
|
+
if path.is_file()
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_project_config(config_dir: Path) -> ProjectConfig:
|
|
102
|
+
"""Read the project settings. `rules.yaml` wins; `config.yaml` fills gaps."""
|
|
103
|
+
rules_file = config_dir / RULES_FILENAME
|
|
104
|
+
legacy_file = config_dir / CONFIG_FILENAME
|
|
105
|
+
if not rules_file.is_file() and not legacy_file.is_file():
|
|
106
|
+
raise ConfigError(
|
|
107
|
+
f"missing {rules_file}. Run `cdec init` to scaffold it."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
raw = load_document(rules_file)
|
|
112
|
+
except RulesFileError as exc:
|
|
113
|
+
raise ConfigError(str(exc)) from exc
|
|
114
|
+
merged: dict[str, Any] = {**_load_legacy_settings(legacy_file), **raw}
|
|
115
|
+
where = rules_file if rules_file.is_file() else legacy_file
|
|
116
|
+
|
|
117
|
+
language = merged.get("language")
|
|
118
|
+
if language not in SUPPORTED_LANGUAGES:
|
|
119
|
+
allowed = ", ".join(SUPPORTED_LANGUAGES)
|
|
120
|
+
raise ConfigError(f"{where}: 'language' must be one of: {allowed}")
|
|
121
|
+
source = merged.get("source")
|
|
122
|
+
if not source:
|
|
123
|
+
raise ConfigError(f"{where}: 'source' is required")
|
|
124
|
+
|
|
125
|
+
# `reference:` is a plain top-level path now. The legacy nesting
|
|
126
|
+
# (`baseline: {reference: ...}`) still resolves.
|
|
127
|
+
reference_value = merged.get("reference")
|
|
128
|
+
if reference_value is None:
|
|
129
|
+
legacy_baseline = merged.get("baseline")
|
|
130
|
+
if isinstance(legacy_baseline, dict):
|
|
131
|
+
reference_value = legacy_baseline.get("reference")
|
|
132
|
+
|
|
133
|
+
output = merged.get("output") or {}
|
|
134
|
+
if not isinstance(output, dict):
|
|
135
|
+
raise ConfigError(f"{where}: 'output' must be a mapping")
|
|
136
|
+
|
|
137
|
+
return ProjectConfig(
|
|
138
|
+
language=language,
|
|
139
|
+
source=_resolve(source, config_dir.parent),
|
|
140
|
+
config_dir=config_dir,
|
|
141
|
+
reference=_resolve(reference_value, config_dir.parent) if reference_value else None,
|
|
142
|
+
json_out=_resolve(output.get("json"), config_dir.parent) if output.get("json") else None,
|
|
143
|
+
log_out=_resolve(output.get("log"), config_dir.parent) if output.get("log") else None,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _load_legacy_settings(path: Path) -> dict[str, Any]:
|
|
148
|
+
if not path.is_file():
|
|
149
|
+
return {}
|
|
150
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
151
|
+
raw = yaml.safe_load(fh) or {}
|
|
152
|
+
if not isinstance(raw, dict):
|
|
153
|
+
raise ConfigError(f"{path}: top-level must be a mapping")
|
|
154
|
+
# `lock:` used to be a settings section; it is now the options of an
|
|
155
|
+
# `implementation-locks` rule, so it is not carried over here.
|
|
156
|
+
return {k: v for k, v in raw.items() if k != "lock"}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_rules(config_dir: Path) -> LoadedRules:
|
|
160
|
+
"""Build the rule objects from the `rules:` list."""
|
|
161
|
+
path = config_dir / RULES_FILENAME
|
|
162
|
+
if not path.is_file():
|
|
163
|
+
# A project still on the legacy layout may have no rules.yaml at all.
|
|
164
|
+
raise ConfigError(f"missing {path}. Run `cdec init` to scaffold it.")
|
|
165
|
+
try:
|
|
166
|
+
raw = load_document(path)
|
|
167
|
+
except RulesFileError as exc:
|
|
168
|
+
raise ConfigError(str(exc)) from exc
|
|
169
|
+
|
|
170
|
+
entries = raw.get("rules") or []
|
|
171
|
+
if not isinstance(entries, list):
|
|
172
|
+
raise ConfigError(f"{path}: 'rules' must be a list")
|
|
173
|
+
|
|
174
|
+
out: list[Rule] = []
|
|
175
|
+
seen_ids: set[str] = set()
|
|
176
|
+
for i, entry in enumerate(entries):
|
|
177
|
+
rule = _build_rule(path, i, entry, seen_ids)
|
|
178
|
+
if rule is not None:
|
|
179
|
+
out.append(rule)
|
|
180
|
+
return LoadedRules(rules=out)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _build_rule(
|
|
184
|
+
path: Path, i: int, entry: Any, seen_ids: set[str]
|
|
185
|
+
) -> Rule | None:
|
|
186
|
+
if not isinstance(entry, dict):
|
|
187
|
+
raise ConfigError(f"{path}: rules[{i}] must be a mapping")
|
|
188
|
+
rule_id = entry.get("id")
|
|
189
|
+
type_name = entry.get("type")
|
|
190
|
+
if not rule_id:
|
|
191
|
+
raise ConfigError(f"{path}: rules[{i}] missing 'id'")
|
|
192
|
+
if rule_id in seen_ids:
|
|
193
|
+
raise ConfigError(f"{path}: duplicate rule id {rule_id!r}")
|
|
194
|
+
seen_ids.add(rule_id)
|
|
195
|
+
if not type_name:
|
|
196
|
+
raise ConfigError(f"{path}: rules[{i}] (id={rule_id}) missing 'type'")
|
|
197
|
+
rule_cls = get_rule_class(type_name)
|
|
198
|
+
if rule_cls is None:
|
|
199
|
+
raise ConfigError(
|
|
200
|
+
f"{path}: rules[{i}] (id={rule_id}) has unknown type {type_name!r}. "
|
|
201
|
+
f"Known types: {', '.join(known_rule_types())}"
|
|
202
|
+
)
|
|
203
|
+
severity_raw = entry.get("severity", "error")
|
|
204
|
+
# YAML 1.1 parses bare `off` / `on` as booleans; coerce back to strings.
|
|
205
|
+
if isinstance(severity_raw, bool):
|
|
206
|
+
severity_raw = "off" if severity_raw is False else "on"
|
|
207
|
+
try:
|
|
208
|
+
severity = Severity(severity_raw)
|
|
209
|
+
except ValueError as exc:
|
|
210
|
+
raise ConfigError(
|
|
211
|
+
f"{path}: rules[{i}] (id={rule_id}) has invalid severity {severity_raw!r}"
|
|
212
|
+
) from exc
|
|
213
|
+
if severity == Severity.OFF:
|
|
214
|
+
return None
|
|
215
|
+
scope = entry.get("scope", rule_cls.default_scope)
|
|
216
|
+
if scope not in ("diff", "snapshot"):
|
|
217
|
+
raise ConfigError(f"{path}: rules[{i}] (id={rule_id}) has invalid scope {scope!r}")
|
|
218
|
+
ignore = entry.get("ignore") or []
|
|
219
|
+
if not isinstance(ignore, list):
|
|
220
|
+
raise ConfigError(f"{path}: rules[{i}] (id={rule_id}) 'ignore' must be a list")
|
|
221
|
+
reserved = {"id", "type", "severity", "scope", "message", "ignore"}
|
|
222
|
+
options: dict[str, Any] = {k: v for k, v in entry.items() if k not in reserved}
|
|
223
|
+
return rule_cls(
|
|
224
|
+
rule_id=rule_id,
|
|
225
|
+
severity=severity,
|
|
226
|
+
scope=scope,
|
|
227
|
+
message=entry.get("message", ""),
|
|
228
|
+
ignore=ignore,
|
|
229
|
+
options=options,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _resolve(path_value: str | None, base: Path) -> Path:
|
|
234
|
+
if path_value is None:
|
|
235
|
+
return base
|
|
236
|
+
p = Path(path_value)
|
|
237
|
+
if p.is_absolute():
|
|
238
|
+
return p
|
|
239
|
+
return (base / p).resolve()
|