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,602 @@
|
|
|
1
|
+
"""Reusable, non-interactive scaffolding primitives.
|
|
2
|
+
|
|
3
|
+
Shared by the `cdec init` subcommand and the interactive session
|
|
4
|
+
(`code_constraints.cli.interactive`). Everything here is pure file I/O + asset resolution —
|
|
5
|
+
no prompting, no `typer.Exit`. Callers decide how to surface results.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from code_constraints.core.model import SUPPORTED_LANGUAGES
|
|
14
|
+
from code_constraints.core.rulesdoc import RULES_FILENAME
|
|
15
|
+
from code_constraints.lint.config import (
|
|
16
|
+
BASELINE_FILENAME,
|
|
17
|
+
CONFIG_FILENAME,
|
|
18
|
+
LOCKS_FILENAME,
|
|
19
|
+
REFERENCE_FILENAME,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
SUPPORTED_LANGS = SUPPORTED_LANGUAGES
|
|
23
|
+
|
|
24
|
+
# Where a copied shim lands in the target project, by language. The file name
|
|
25
|
+
# matches the convention in examples/python_demo/cdec_rules.py — the shim sits at
|
|
26
|
+
# the project root so tagged code can `from cdec_rules import …` /
|
|
27
|
+
# `using CodeConstraints.Rules;`.
|
|
28
|
+
_SHIM_TARGETS = {
|
|
29
|
+
"python": ("shims/python/cdec_rules.py", "cdec_rules.py"),
|
|
30
|
+
"csharp": ("shims/csharp/CodeConstraintsRules.cs", "CodeConstraintsRules.cs"),
|
|
31
|
+
"julia": ("shims/julia/CdecRules.jl", "CdecRules.jl"),
|
|
32
|
+
# Lua and Odin carry tags in `@cdec` annotation comments rather than in a
|
|
33
|
+
# language construct, so their shims are vocabulary references plus runtime
|
|
34
|
+
# no-ops. They are still copied, so the tag list lives in the project.
|
|
35
|
+
"lua": ("shims/lua/cdec_rules.lua", "cdec_rules.lua"),
|
|
36
|
+
"odin": ("shims/odin/cdec_rules.odin", "cdec_rules.odin"),
|
|
37
|
+
# typescript / svelte have no shims yet.
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Claude assets deployed into target projects: agents plus the architecture-loop
|
|
41
|
+
# skill (the propose → review → lock workflow). Each entry is the destination
|
|
42
|
+
# path inside the user's project. The source copies live in the package at
|
|
43
|
+
# `cli/_assets/{agents,skills}/` — this repo does not track its own `.claude/`.
|
|
44
|
+
_AGENT_RELS = [
|
|
45
|
+
".claude/agents/cdec-architect.md",
|
|
46
|
+
".claude/agents/oop-refactor-architect.md",
|
|
47
|
+
".claude/skills/cdec-architecture-loop/SKILL.md",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ScaffoldError(RuntimeError):
|
|
52
|
+
"""Raised when an asset can't be located or a target already exists."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------- asset resolution ----------
|
|
56
|
+
|
|
57
|
+
def _repo_root() -> Path | None:
|
|
58
|
+
"""Walk up from this file looking for the harness repo root (the dir that
|
|
59
|
+
holds both `pyproject.toml` and `shims/`). Present in editable installs."""
|
|
60
|
+
for parent in Path(__file__).resolve().parents:
|
|
61
|
+
if (parent / "pyproject.toml").is_file() and (parent / "shims").is_dir():
|
|
62
|
+
return parent
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _asset(rel: str) -> Path:
|
|
67
|
+
"""Resolve a bundled asset by its repo-relative path (e.g.
|
|
68
|
+
"shims/python/cdec_rules.py" or ".claude/agents/cdec-architect.md").
|
|
69
|
+
|
|
70
|
+
Shims prefer the repo-relative source-of-truth file (editable installs) and
|
|
71
|
+
fall back to the packaged copy under `code_constraints/cli/_assets/…`, where
|
|
72
|
+
`force-include` maps them in. The `.claude/…` rels have no repo-relative
|
|
73
|
+
original, so they always resolve to the packaged copy. Raises ScaffoldError
|
|
74
|
+
if missing.
|
|
75
|
+
"""
|
|
76
|
+
# `.claude/…` rels are destination paths only — never read them back out of
|
|
77
|
+
# a repo root, or an editable install would prefer a developer's own
|
|
78
|
+
# untracked `.claude/` over the packaged source of truth.
|
|
79
|
+
root = None if rel.startswith(".claude/") else _repo_root()
|
|
80
|
+
if root is not None:
|
|
81
|
+
candidate = root / rel
|
|
82
|
+
if candidate.is_file():
|
|
83
|
+
return candidate
|
|
84
|
+
|
|
85
|
+
# Packaged copy: assets are flattened under code_constraints/cli/_assets/.
|
|
86
|
+
# shims/python/cdec_rules.py -> _assets/shims/python/cdec_rules.py
|
|
87
|
+
# .claude/agents/cdec-architect.md -> _assets/agents/cdec-architect.md
|
|
88
|
+
# .claude/skills/<name>/SKILL.md -> _assets/skills/<name>/SKILL.md
|
|
89
|
+
packaged_rel = rel
|
|
90
|
+
if rel.startswith(".claude/agents/"):
|
|
91
|
+
packaged_rel = "agents/" + rel.split("/")[-1]
|
|
92
|
+
elif rel.startswith(".claude/skills/"):
|
|
93
|
+
packaged_rel = rel.removeprefix(".claude/")
|
|
94
|
+
try:
|
|
95
|
+
base = importlib.resources.files("code_constraints.cli") / "_assets"
|
|
96
|
+
candidate = Path(str(base / packaged_rel))
|
|
97
|
+
if candidate.is_file():
|
|
98
|
+
return candidate
|
|
99
|
+
except (ModuleNotFoundError, FileNotFoundError):
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
raise ScaffoldError(f"could not locate bundled asset: {rel}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------- .cdec/ scaffolding ----------
|
|
106
|
+
|
|
107
|
+
def init_cdec_config(
|
|
108
|
+
config_dir: Path, lang: str, source: Path, force: bool
|
|
109
|
+
) -> list[Path]:
|
|
110
|
+
"""Scaffold a `.cdec/` folder and snapshot a reference model from `source`.
|
|
111
|
+
|
|
112
|
+
One `rules.yaml` carries the settings and the rules; the exceptions granted
|
|
113
|
+
and the lock ledger are appended to its tool-managed tail as they are
|
|
114
|
+
earned. Returns the files written.
|
|
115
|
+
|
|
116
|
+
Raises ScaffoldError on an unsupported language, or if a target file exists
|
|
117
|
+
and `force` is False.
|
|
118
|
+
"""
|
|
119
|
+
if lang not in SUPPORTED_LANGS:
|
|
120
|
+
raise ScaffoldError(f"unsupported language: {lang}")
|
|
121
|
+
|
|
122
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
written: list[Path] = []
|
|
124
|
+
|
|
125
|
+
files = [
|
|
126
|
+
(
|
|
127
|
+
config_dir / RULES_FILENAME,
|
|
128
|
+
_RULES_TEMPLATE.format(lang=lang, source=_posix(source)),
|
|
129
|
+
),
|
|
130
|
+
(config_dir / "README.md", _README_TEMPLATE),
|
|
131
|
+
]
|
|
132
|
+
for path, content in files:
|
|
133
|
+
if path.exists() and not force:
|
|
134
|
+
raise ScaffoldError(f"refusing to overwrite {path} (use force)")
|
|
135
|
+
path.write_text(content, encoding="utf-8")
|
|
136
|
+
written.append(path)
|
|
137
|
+
|
|
138
|
+
gitignore = config_dir / ".gitignore"
|
|
139
|
+
gitignore.write_text("cache/\n", encoding="utf-8")
|
|
140
|
+
written.append(gitignore)
|
|
141
|
+
|
|
142
|
+
# Snapshot a reference model up front. It is the baseline every diff-scope
|
|
143
|
+
# rule needs, and "where does reference.xmi come from" is the first question
|
|
144
|
+
# anybody asks — so scaffolding answers it rather than deferring it.
|
|
145
|
+
reference_path = config_dir / REFERENCE_FILENAME
|
|
146
|
+
if source.exists():
|
|
147
|
+
from code_constraints.core.xmi_writer import write_project
|
|
148
|
+
|
|
149
|
+
project = _parse_project(source, lang)
|
|
150
|
+
write_project(project, reference_path)
|
|
151
|
+
written.append(reference_path)
|
|
152
|
+
|
|
153
|
+
return written
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def migrate_cdec_config(config_dir: Path) -> tuple[list[Path], list[Path]]:
|
|
157
|
+
"""Fold `config.yaml` / `baseline.yaml` / `locks.yaml` into `rules.yaml`.
|
|
158
|
+
|
|
159
|
+
Returns (files written, files removed). The old files are deleted only once
|
|
160
|
+
their content has been read back out of the new one, so a failed migration
|
|
161
|
+
leaves the project exactly as it was.
|
|
162
|
+
"""
|
|
163
|
+
import yaml
|
|
164
|
+
|
|
165
|
+
from code_constraints.core.rulesdoc import load_document, write_sections
|
|
166
|
+
from code_constraints.lock.store import load_locks, write_locks
|
|
167
|
+
from code_constraints.waivers.store import load_waivers, save_waivers
|
|
168
|
+
|
|
169
|
+
rules_path = config_dir / RULES_FILENAME
|
|
170
|
+
legacy_config = config_dir / CONFIG_FILENAME
|
|
171
|
+
legacy_baseline = config_dir / BASELINE_FILENAME
|
|
172
|
+
legacy_locks = config_dir / LOCKS_FILENAME
|
|
173
|
+
if not any(p.is_file() for p in (legacy_config, legacy_baseline, legacy_locks)):
|
|
174
|
+
return [], []
|
|
175
|
+
|
|
176
|
+
# 1. Settings, prepended as plain top-level keys above whatever rules.yaml
|
|
177
|
+
# already holds, so the rules and their comments are untouched.
|
|
178
|
+
if legacy_config.is_file():
|
|
179
|
+
with legacy_config.open(encoding="utf-8") as fh:
|
|
180
|
+
old = yaml.safe_load(fh) or {}
|
|
181
|
+
if not isinstance(old, dict):
|
|
182
|
+
raise ScaffoldError(f"{legacy_config}: top-level must be a mapping")
|
|
183
|
+
existing = load_document(rules_path) if rules_path.is_file() else {}
|
|
184
|
+
header = _migrated_settings_header(old, existing)
|
|
185
|
+
body = rules_path.read_text(encoding="utf-8") if rules_path.is_file() else _EMPTY_RULES
|
|
186
|
+
rules_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
rules_path.write_text(header + body, encoding="utf-8")
|
|
188
|
+
# A `lock:` settings block becomes an `implementation-locks` rule. Only
|
|
189
|
+
# a configured one is carried over: inventing a rule the project never
|
|
190
|
+
# opted into would turn a migration into a new gate.
|
|
191
|
+
lock_cfg = old.get("lock") or {}
|
|
192
|
+
if isinstance(lock_cfg, dict) and lock_cfg.get("targets"):
|
|
193
|
+
targets = ", ".join(f'"{t}"' for t in lock_cfg.get("targets") or [])
|
|
194
|
+
with rules_path.open("a", encoding="utf-8") as fh:
|
|
195
|
+
fh.write(
|
|
196
|
+
_MIGRATED_LOCK_RULE.format(
|
|
197
|
+
targets=f"[{targets}]",
|
|
198
|
+
include_docstrings=str(
|
|
199
|
+
bool(lock_cfg.get("include_docstrings", False))
|
|
200
|
+
).lower(),
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# 2. Exceptions and locks, read through the loaders that already understand
|
|
205
|
+
# both the old and the new shape, then written back in the new one.
|
|
206
|
+
store = load_waivers(config_dir)
|
|
207
|
+
entries = load_locks(config_dir)
|
|
208
|
+
if store.waivers:
|
|
209
|
+
save_waivers(config_dir, store)
|
|
210
|
+
if entries:
|
|
211
|
+
write_locks(config_dir, entries.values())
|
|
212
|
+
|
|
213
|
+
# 3. Only now that everything demonstrably landed, drop the old files.
|
|
214
|
+
reread = load_document(rules_path)
|
|
215
|
+
if store.waivers and not reread.get("exceptions"):
|
|
216
|
+
raise ScaffoldError(f"migration aborted: exceptions did not land in {rules_path}")
|
|
217
|
+
if entries and not reread.get("locks"):
|
|
218
|
+
raise ScaffoldError(f"migration aborted: locks did not land in {rules_path}")
|
|
219
|
+
|
|
220
|
+
removed: list[Path] = []
|
|
221
|
+
for path in (legacy_config, legacy_baseline, legacy_locks):
|
|
222
|
+
if path.is_file():
|
|
223
|
+
path.unlink()
|
|
224
|
+
removed.append(path)
|
|
225
|
+
return [rules_path], removed
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _migrated_settings_header(old: dict, existing: dict) -> str:
|
|
229
|
+
"""Render a legacy config.yaml's settings as rules.yaml top-level keys.
|
|
230
|
+
|
|
231
|
+
Keys already present in rules.yaml win — that is the file the user has been
|
|
232
|
+
editing, so it is the more current statement of intent.
|
|
233
|
+
"""
|
|
234
|
+
lines = ["# Project settings (migrated from config.yaml).\n"]
|
|
235
|
+
lines.append(f"language: {existing.get('language') or old.get('language') or 'python'}\n")
|
|
236
|
+
lines.append(f"source: {existing.get('source') or old.get('source') or '.'}\n")
|
|
237
|
+
reference = existing.get("reference")
|
|
238
|
+
if reference is None:
|
|
239
|
+
legacy_baseline = old.get("baseline")
|
|
240
|
+
if isinstance(legacy_baseline, dict):
|
|
241
|
+
reference = legacy_baseline.get("reference")
|
|
242
|
+
if reference:
|
|
243
|
+
lines.append(f"reference: {reference}\n")
|
|
244
|
+
output = existing.get("output") or old.get("output") or {}
|
|
245
|
+
if isinstance(output, dict) and (output.get("json") or output.get("log")):
|
|
246
|
+
lines.append("output:\n")
|
|
247
|
+
if output.get("json"):
|
|
248
|
+
lines.append(f" json: {output['json']}\n")
|
|
249
|
+
if output.get("log"):
|
|
250
|
+
lines.append(f" log: {output['log']}\n")
|
|
251
|
+
lines.append("\n")
|
|
252
|
+
return "".join(lines)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------- copy helpers ----------
|
|
256
|
+
|
|
257
|
+
def copy_agents(project_root: Path, force: bool = False) -> list[Path]:
|
|
258
|
+
"""Copy all code-constraints Claude agents into <project>/.claude/agents/.
|
|
259
|
+
Returns the destination paths written. Raises ScaffoldError if any target
|
|
260
|
+
exists and `force` is False."""
|
|
261
|
+
written: list[Path] = []
|
|
262
|
+
for rel in _AGENT_RELS:
|
|
263
|
+
src = _asset(rel)
|
|
264
|
+
dest = project_root / rel
|
|
265
|
+
if dest.exists() and not force:
|
|
266
|
+
raise ScaffoldError(f"refusing to overwrite {dest} (use --force)")
|
|
267
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
|
269
|
+
written.append(dest)
|
|
270
|
+
return written
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def copy_architect_agent(project_root: Path, force: bool = False) -> Path:
|
|
274
|
+
"""Compatibility shim — copies all agents and returns the first path."""
|
|
275
|
+
return copy_agents(project_root, force=force)[0]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def copy_shims(project_root: Path, lang: str, force: bool = False) -> list[Path]:
|
|
279
|
+
"""Copy the language shim into the project root. Returns the destinations
|
|
280
|
+
written (empty list when the language has no shim). Raises ScaffoldError if a
|
|
281
|
+
target exists and `force` is False."""
|
|
282
|
+
entry = _SHIM_TARGETS.get(lang)
|
|
283
|
+
if entry is None:
|
|
284
|
+
return []
|
|
285
|
+
src_rel, dest_name = entry
|
|
286
|
+
src = _asset(src_rel)
|
|
287
|
+
dest = project_root / dest_name
|
|
288
|
+
if dest.exists() and not force:
|
|
289
|
+
raise ScaffoldError(f"refusing to overwrite {dest} (use force)")
|
|
290
|
+
dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
|
291
|
+
return [dest]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def has_shim(lang: str) -> bool:
|
|
295
|
+
return lang in _SHIM_TARGETS
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------- CI/CD script generation ----------
|
|
299
|
+
|
|
300
|
+
def write_ci_scripts(project_root: Path, lang: str, source: Path) -> list[Path]:
|
|
301
|
+
"""Write `cdec-ci.bat` (Windows) and `cdec-ci.sh` (bash) into the project root.
|
|
302
|
+
|
|
303
|
+
Both are one line of real work: `cdec check` is the whole gate, and its exit
|
|
304
|
+
code is the whole answer."""
|
|
305
|
+
src = _posix(source)
|
|
306
|
+
bat = project_root / "cdec-ci.bat"
|
|
307
|
+
sh = project_root / "cdec-ci.sh"
|
|
308
|
+
bat.write_text(_CI_BAT_TEMPLATE.format(lang=lang, source=src), encoding="utf-8")
|
|
309
|
+
sh.write_text(_CI_SH_TEMPLATE.format(lang=lang, source=src), encoding="utf-8")
|
|
310
|
+
return [bat, sh]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------- shared internals ----------
|
|
314
|
+
|
|
315
|
+
def _parse_project(path: Path, lang: str):
|
|
316
|
+
if lang == "python":
|
|
317
|
+
from code_constraints.python import parse_project
|
|
318
|
+
|
|
319
|
+
return parse_project(path)
|
|
320
|
+
if lang == "csharp":
|
|
321
|
+
from code_constraints.csharp import parse_project
|
|
322
|
+
|
|
323
|
+
return parse_project(path)
|
|
324
|
+
if lang == "typescript":
|
|
325
|
+
from code_constraints.typescript import parse_project
|
|
326
|
+
|
|
327
|
+
return parse_project(path)
|
|
328
|
+
if lang == "svelte":
|
|
329
|
+
from code_constraints.svelte import parse_project
|
|
330
|
+
|
|
331
|
+
return parse_project(path)
|
|
332
|
+
if lang == "odin":
|
|
333
|
+
from code_constraints.odin import parse_project
|
|
334
|
+
|
|
335
|
+
return parse_project(path)
|
|
336
|
+
if lang == "lua":
|
|
337
|
+
from code_constraints.lua import parse_project
|
|
338
|
+
|
|
339
|
+
return parse_project(path)
|
|
340
|
+
if lang == "julia":
|
|
341
|
+
from code_constraints.julia import parse_project
|
|
342
|
+
|
|
343
|
+
return parse_project(path)
|
|
344
|
+
raise ScaffoldError(f"unsupported language: {lang}")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _posix(p: Path) -> str:
|
|
348
|
+
return str(p).replace("\\", "/")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
_EMPTY_RULES = "rules: []\n"
|
|
352
|
+
|
|
353
|
+
_MIGRATED_LOCK_RULE = """
|
|
354
|
+
# Migrated from the `lock:` section of config.yaml. Locks are a rule now, so
|
|
355
|
+
# they are configured, reported and gated exactly like every other law here.
|
|
356
|
+
- id: frozen-implementations
|
|
357
|
+
type: implementation-locks
|
|
358
|
+
severity: error
|
|
359
|
+
include_docstrings: {include_docstrings}
|
|
360
|
+
targets: {targets}
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
_RULES_TEMPLATE = """\
|
|
364
|
+
# ============================================================================
|
|
365
|
+
# .cdec/rules.yaml — everything `cdec check` needs, in one committed file.
|
|
366
|
+
#
|
|
367
|
+
# settings what to check and where (below)
|
|
368
|
+
# rules: the laws that are enforced (below)
|
|
369
|
+
# exceptions: violations you accepted, and why (written by the tool)
|
|
370
|
+
# locks: digests of frozen implementations (written by the tool)
|
|
371
|
+
#
|
|
372
|
+
# The last two live in a marked section at the end of the file. The tool
|
|
373
|
+
# rewrites only that section, so every comment and message you write up here
|
|
374
|
+
# survives untouched.
|
|
375
|
+
#
|
|
376
|
+
# One command runs all of it:
|
|
377
|
+
#
|
|
378
|
+
# cdec check verify everything
|
|
379
|
+
# cdec check --automatic-exceptions reference snapshot .cdec/reference.xmi
|
|
380
|
+
# cdec check --automatic-exceptions rules grandfather today's violations
|
|
381
|
+
# cdec check --automatic-exceptions locks record digests for @locked code
|
|
382
|
+
# cdec exceptions allow V-1A2B3C4D --reason .. accept one issue, with a reason
|
|
383
|
+
# ============================================================================
|
|
384
|
+
|
|
385
|
+
language: {lang} # python | csharp | typescript | svelte | odin | lua | julia
|
|
386
|
+
source: {source} # the source tree `cdec check` parses
|
|
387
|
+
|
|
388
|
+
# The baseline every `scope: diff` rule compares against, and the model the
|
|
389
|
+
# `reference-architecture` rule gates on. `cdec init` snapshots it for you;
|
|
390
|
+
# re-snapshot with `cdec check --automatic-exceptions reference`.
|
|
391
|
+
reference: .cdec/reference.xmi
|
|
392
|
+
|
|
393
|
+
output:
|
|
394
|
+
json: null # optional default for --json-out
|
|
395
|
+
log: null # optional default for --log-out
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
# Rules. Every entry takes:
|
|
399
|
+
# id — stable identifier, and the heading violations are grouped under
|
|
400
|
+
# type — which rule to run (list below)
|
|
401
|
+
# severity — error | warning | off
|
|
402
|
+
# scope — diff | snapshot (defaults per rule type)
|
|
403
|
+
# message — what to print when it fires. Supports {{placeholders}} and YAML
|
|
404
|
+
# block scalars (`message: |`). USE IT: a rule that explains why
|
|
405
|
+
# it exists teaches; one that just says "violation" breeds
|
|
406
|
+
# resentment.
|
|
407
|
+
# ignore — list of qualified-name globs to exempt
|
|
408
|
+
#
|
|
409
|
+
# `cdec check` is opt-in — an empty list enforces nothing. Add laws one at a
|
|
410
|
+
# time, as the team agrees on them.
|
|
411
|
+
#
|
|
412
|
+
# --- model rules (read the parsed architecture) ---
|
|
413
|
+
# no-new-classes, no-removed-classes ....... structural drift (scope: diff)
|
|
414
|
+
# frozen-members ........................... a class's public shape (diff)
|
|
415
|
+
# frozen-rules ............................. the constraint tags themselves (diff)
|
|
416
|
+
# forbidden-references ..................... class A may not reference class B
|
|
417
|
+
# forbidden-package-references ............. package A may not reference package B
|
|
418
|
+
# no-cyclic-package-dependencies ........... no dependency cycles
|
|
419
|
+
# layer-dependencies ....................... @layer tags + an allowed-direction matrix
|
|
420
|
+
# subclass-naming .......................... subclasses of X must be named Y
|
|
421
|
+
# dangling-classes ......................... nothing references this class
|
|
422
|
+
# max-class-fanout ......................... a class references too many others
|
|
423
|
+
#
|
|
424
|
+
# --- source rules (re-read the code itself) ---
|
|
425
|
+
# tag-conformance .......................... the implementation obeys its
|
|
426
|
+
# @sealed / @immutable / @factory /
|
|
427
|
+
# @no_instantiation tags
|
|
428
|
+
# implementation-locks ..................... an @locked body may not change
|
|
429
|
+
# reference-architecture ................... no structural deviation from
|
|
430
|
+
# reference.xmi at all
|
|
431
|
+
#
|
|
432
|
+
# Placeholders available in `message`, by type:
|
|
433
|
+
# no-new-classes / no-removed-classes / dangling-classes:
|
|
434
|
+
# {{qualified_name}} (max-class-fanout adds {{fanout}}, {{limit}})
|
|
435
|
+
# frozen-members: {{qualified_name}}, {{member}}, {{kind}}, {{action}}
|
|
436
|
+
# forbidden-*-references: {{qualified_name}}, {{source}}, {{target}}
|
|
437
|
+
# subclass-naming: {{qualified_name}}, {{name}}, {{base}}, {{pattern}}
|
|
438
|
+
# no-cyclic-package-dependencies: {{cycle}}
|
|
439
|
+
# frozen-rules: {{qualified_name}}, {{rule}}, {{member}}, {{action}}
|
|
440
|
+
# layer-dependencies: {{qualified_name}}, {{source}}, {{target}},
|
|
441
|
+
# {{source_layer}}, {{target_layer}}
|
|
442
|
+
# tag-conformance: {{qualified_name}}, {{rule}}, {{detail}}, {{message}}
|
|
443
|
+
# implementation-locks: {{qualified_name}}, {{kind}}, {{message}}
|
|
444
|
+
# reference-architecture: {{qualified_name}}, {{category}}, {{member}}, {{message}}
|
|
445
|
+
#
|
|
446
|
+
# Every rule and every source tag is documented with options and worked
|
|
447
|
+
# pass/fail examples in docs/RULES_CATALOGUE.md.
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
rules: []
|
|
451
|
+
|
|
452
|
+
# Examples — uncomment and adapt:
|
|
453
|
+
#
|
|
454
|
+
# - id: domain-must-not-depend-on-ui
|
|
455
|
+
# type: forbidden-package-references
|
|
456
|
+
# severity: error
|
|
457
|
+
# from: ["myapp.domain.**"]
|
|
458
|
+
# to: ["myapp.ui.**"]
|
|
459
|
+
# message: |
|
|
460
|
+
# Layering violation: '{{source}}' must not depend on '{{target}}'.
|
|
461
|
+
# The domain is pure business rules; move the reference to whichever
|
|
462
|
+
# package owns the workflow.
|
|
463
|
+
#
|
|
464
|
+
# - id: no-new-classes
|
|
465
|
+
# type: no-new-classes
|
|
466
|
+
# severity: error
|
|
467
|
+
# ignore: ["tests.**"]
|
|
468
|
+
#
|
|
469
|
+
# - id: layering
|
|
470
|
+
# type: layer-dependencies
|
|
471
|
+
# severity: error
|
|
472
|
+
# allow:
|
|
473
|
+
# ui: [domain]
|
|
474
|
+
# domain: [data]
|
|
475
|
+
# data: []
|
|
476
|
+
#
|
|
477
|
+
# - id: tags-must-be-honoured
|
|
478
|
+
# type: tag-conformance
|
|
479
|
+
# severity: error
|
|
480
|
+
#
|
|
481
|
+
# - id: frozen-implementations
|
|
482
|
+
# type: implementation-locks
|
|
483
|
+
# severity: error
|
|
484
|
+
#
|
|
485
|
+
# - id: public-shape-is-frozen
|
|
486
|
+
# type: reference-architecture
|
|
487
|
+
# severity: error
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
_README_TEMPLATE = """\
|
|
491
|
+
# `.cdec/` — architectural constraints for this project
|
|
492
|
+
|
|
493
|
+
Two files, and one command that reads them.
|
|
494
|
+
|
|
495
|
+
| File | What it is |
|
|
496
|
+
|---|---|
|
|
497
|
+
| `rules.yaml` | Settings, the rules enforced, the exceptions granted, and the digests of frozen implementations. Commit it. |
|
|
498
|
+
| `reference.xmi` | A snapshot of the architecture, used as the baseline for `scope: diff` rules and by the `reference-architecture` rule. Commit it. |
|
|
499
|
+
| `cache/` | Transient parse artefacts. Gitignored. |
|
|
500
|
+
|
|
501
|
+
```
|
|
502
|
+
cdec check
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
That is the whole gate. Architectural rules, source-tag conformance,
|
|
506
|
+
implementation locks and the reference gate are all rule types in `rules.yaml`,
|
|
507
|
+
so there is one command to run in CI and one exit code to read.
|
|
508
|
+
|
|
509
|
+
## Accepting things
|
|
510
|
+
|
|
511
|
+
Every issue prints a stable key (`V-` a configured rule, `F-` tag conformance,
|
|
512
|
+
`L-` a lock, `R-` a reference deviation). The key is derived from what the issue
|
|
513
|
+
*is*, never from where it sits, so reformatting or moving code never invalidates
|
|
514
|
+
a decision you recorded.
|
|
515
|
+
|
|
516
|
+
```
|
|
517
|
+
cdec exceptions allow V-1A2B3C4D --reason "agreed in ARCH-42"
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
For a batch, save the report, mark the lines you accept with `[ALLOW]` (or
|
|
521
|
+
`[ALLOW: reason]`), and apply the file:
|
|
522
|
+
|
|
523
|
+
```
|
|
524
|
+
cdec check --log-out check.log
|
|
525
|
+
cdec exceptions patch --file check.log
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
`cdec exceptions list` shows what is accepted and why; `remove KEY` withdraws
|
|
529
|
+
it; `prune` drops exceptions whose issue no longer occurs — worth running
|
|
530
|
+
periodically, since a stale one pre-approves the next violation just the same.
|
|
531
|
+
|
|
532
|
+
To grandfather everything at once when adopting a rule on an existing codebase:
|
|
533
|
+
|
|
534
|
+
```
|
|
535
|
+
cdec check --automatic-exceptions rules
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
## Locks are the exception to exceptions
|
|
539
|
+
|
|
540
|
+
`L-` issues cannot be accepted through `cdec exceptions`. Tag a class or
|
|
541
|
+
function `@locked` (Python) / `[Locked]` (C#), add an `implementation-locks`
|
|
542
|
+
rule, and record the digest:
|
|
543
|
+
|
|
544
|
+
```
|
|
545
|
+
cdec check --automatic-exceptions locks
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
That is safe for anyone to run: without `--force` it can only *add* locks, never
|
|
549
|
+
erase the evidence that a frozen body changed. Accepting a change to locked code
|
|
550
|
+
is the privileged step:
|
|
551
|
+
|
|
552
|
+
```
|
|
553
|
+
cdec check --automatic-exceptions locks --force
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
It rewrites the `locks:` section, which is a reviewable diff. Put `rules.yaml`
|
|
557
|
+
behind a CODEOWNERS entry and re-baselining becomes a lead-only action that
|
|
558
|
+
always leaves a trail. To ship without re-baselining,
|
|
559
|
+
`cdec check --bypass-locks --bypass-reason "..."` prints an audit banner and
|
|
560
|
+
passes; reject bypassed runs in CI by checking `summary.bypassed` in
|
|
561
|
+
`--json-out`.
|
|
562
|
+
|
|
563
|
+
## CI recipes
|
|
564
|
+
|
|
565
|
+
Pre-merge, against the target branch:
|
|
566
|
+
|
|
567
|
+
```
|
|
568
|
+
cdec check --base-ref origin/main --json-out lint.json
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
Against the committed reference:
|
|
572
|
+
|
|
573
|
+
```
|
|
574
|
+
cdec check
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Either exits 1 on any violation at or above `--fail-on` severity (default:
|
|
578
|
+
`error`).
|
|
579
|
+
"""
|
|
580
|
+
|
|
581
|
+
_CI_BAT_TEMPLATE = """\
|
|
582
|
+
@echo off
|
|
583
|
+
REM Architectural CI check. Generated by `cdec`.
|
|
584
|
+
REM One command: every rule in .cdec/rules.yaml, one exit code.
|
|
585
|
+
setlocal
|
|
586
|
+
|
|
587
|
+
python -m code_constraints.cli check --config .cdec --source {source}
|
|
588
|
+
if errorlevel 1 exit /b 1
|
|
589
|
+
|
|
590
|
+
echo code-constraints checks passed.
|
|
591
|
+
"""
|
|
592
|
+
|
|
593
|
+
_CI_SH_TEMPLATE = """\
|
|
594
|
+
#!/usr/bin/env bash
|
|
595
|
+
# Architectural CI check. Generated by `cdec`.
|
|
596
|
+
# One command: every rule in .cdec/rules.yaml, one exit code.
|
|
597
|
+
set -euo pipefail
|
|
598
|
+
|
|
599
|
+
python -m code_constraints.cli check --config .cdec --source {source}
|
|
600
|
+
|
|
601
|
+
echo "code-constraints checks passed."
|
|
602
|
+
"""
|