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