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,1203 @@
|
|
|
1
|
+
"""The MCP tool surface over code-constraints.
|
|
2
|
+
|
|
3
|
+
Every tool calls the library in-process rather than shelling out to `cdec`, for
|
|
4
|
+
two reasons: results come back as structured JSON instead of human text, and the
|
|
5
|
+
issue keys (`V-`/`F-`/`L-`/`R-`) are derived by exactly the same code path the
|
|
6
|
+
CLI uses — so a key an agent reads from `cdec_check` is the same key
|
|
7
|
+
`cdec exceptions allow` accepts on the command line.
|
|
8
|
+
|
|
9
|
+
Path arguments are resolved against the server's project root (`--project-root`,
|
|
10
|
+
`$CDEC_PROJECT_ROOT`, else the process CWD), so an agent can pass repo-relative
|
|
11
|
+
paths without knowing where the harness launched the server.
|
|
12
|
+
|
|
13
|
+
**stdout is the MCP transport.** Nothing in this module may print — the engines
|
|
14
|
+
are all called as libraries (the CLI's `typer.echo` reporting is reimplemented
|
|
15
|
+
here as return values) and logging is pinned to stderr in `__main__`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING, Any, Literal, Optional, cast, get_args
|
|
28
|
+
from urllib.parse import quote
|
|
29
|
+
|
|
30
|
+
from mcp.server import MCPServer
|
|
31
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
32
|
+
|
|
33
|
+
from code_constraints.core.rulesdoc import RULES_FILENAME
|
|
34
|
+
from code_constraints.lint.config import (
|
|
35
|
+
REFERENCE_FILENAME,
|
|
36
|
+
ConfigError,
|
|
37
|
+
legacy_files,
|
|
38
|
+
load_project_config,
|
|
39
|
+
load_rules,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
# Annotation-only: the engines are imported lazily inside each tool so that
|
|
44
|
+
# building the server (which every harness does at startup) stays cheap.
|
|
45
|
+
from code_constraints.core.model import Project
|
|
46
|
+
from code_constraints.lint.config import LoadedRules, ProjectConfig
|
|
47
|
+
from code_constraints.waivers import ApplyResult, Collected, Issue
|
|
48
|
+
|
|
49
|
+
Language = Literal[
|
|
50
|
+
"python", "csharp", "typescript", "svelte", "odin", "lua", "julia"
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
SERVER_INSTRUCTIONS = """\
|
|
54
|
+
code-constraints (`cdec`) enforces architectural and implementation constraints \
|
|
55
|
+
on a codebase. Everything lives in one committed file, `.cdec/rules.yaml`: the \
|
|
56
|
+
settings, the rules enforced, the exceptions granted, and the digests of frozen \
|
|
57
|
+
implementations.
|
|
58
|
+
|
|
59
|
+
`cdec_check` is the whole gate. Four kinds of rule run inside it, and each one
|
|
60
|
+
carries its own key prefix so you can tell them apart:
|
|
61
|
+
|
|
62
|
+
* V- configured architectural rules — drift, dependencies, naming, shape
|
|
63
|
+
* F- `tag-conformance` ............. does the code OBEY its @sealed /
|
|
64
|
+
@immutable / @factory / @no_instantiation
|
|
65
|
+
tags, checked in the method bodies
|
|
66
|
+
* L- `implementation-locks` ........ did an @locked body CHANGE at all
|
|
67
|
+
* R- `reference-architecture` ...... any structural deviation from
|
|
68
|
+
.cdec/reference.xmi
|
|
69
|
+
|
|
70
|
+
Typical flows:
|
|
71
|
+
|
|
72
|
+
Gate a change -> cdec_check. One call, one verdict.
|
|
73
|
+
Triage everything -> cdec_issues, one keyed list across every rule.
|
|
74
|
+
Accept a violation -> cdec_allow(keys=["V-1A2B3C4D"], reason="why").
|
|
75
|
+
Locks are NOT acceptable this way; approve one with
|
|
76
|
+
cdec_accept(what=["locks"], force=True), which leaves a
|
|
77
|
+
reviewable diff on the `locks:` section of rules.yaml.
|
|
78
|
+
Adopt on old code -> cdec_accept(what=["rules"]) grandfathers today's
|
|
79
|
+
violations so only NEW ones fail.
|
|
80
|
+
Agree a design -> write a model .json, cdec_propose it (the browser shows
|
|
81
|
+
it diffed against the code), iterate, then
|
|
82
|
+
cdec_reference_set to lock it as the target.
|
|
83
|
+
|
|
84
|
+
Every issue carries a stable key derived from its identity, never from a file
|
|
85
|
+
offset: re-running over unchanged code returns the same keys, and inserting
|
|
86
|
+
lines above an element does not move them.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------
|
|
91
|
+
# root / path resolution
|
|
92
|
+
# --------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class _Roots:
|
|
96
|
+
"""Where the server resolves relative paths from."""
|
|
97
|
+
|
|
98
|
+
project_root: Path
|
|
99
|
+
|
|
100
|
+
def path(self, value: str | None, default: Path | None = None) -> Path | None:
|
|
101
|
+
"""Resolve a caller-supplied path against the project root."""
|
|
102
|
+
if value is None:
|
|
103
|
+
return default
|
|
104
|
+
p = Path(value).expanduser()
|
|
105
|
+
return (p if p.is_absolute() else self.project_root / p).resolve()
|
|
106
|
+
|
|
107
|
+
def config_dir(self, value: str | None) -> Path:
|
|
108
|
+
resolved = self.path(value, self.project_root / ".cdec")
|
|
109
|
+
assert resolved is not None # default is non-None
|
|
110
|
+
return resolved
|
|
111
|
+
|
|
112
|
+
def require_dir(self, value: str | None, *, what: str) -> Path:
|
|
113
|
+
p = self.path(value)
|
|
114
|
+
if p is None:
|
|
115
|
+
raise ToolError(f"{what} is required")
|
|
116
|
+
if not p.is_dir():
|
|
117
|
+
raise ToolError(f"{what} is not a directory: {p}")
|
|
118
|
+
return p
|
|
119
|
+
|
|
120
|
+
def require_file(self, value: str | None, *, what: str) -> Path:
|
|
121
|
+
p = self.path(value)
|
|
122
|
+
if p is None:
|
|
123
|
+
raise ToolError(f"{what} is required")
|
|
124
|
+
if not p.is_file():
|
|
125
|
+
raise ToolError(f"{what} not found: {p}")
|
|
126
|
+
return p
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _rel(roots: _Roots, path: Path) -> str:
|
|
130
|
+
"""Render a path relative to the project root when it lives inside it, so
|
|
131
|
+
tool output reads like the repo the agent is working in."""
|
|
132
|
+
try:
|
|
133
|
+
return str(path.relative_to(roots.project_root)).replace("\\", "/")
|
|
134
|
+
except ValueError:
|
|
135
|
+
return str(path)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_inputs(
|
|
139
|
+
roots: _Roots,
|
|
140
|
+
source: str | None,
|
|
141
|
+
lang: str | None,
|
|
142
|
+
reference: str | None,
|
|
143
|
+
config_dir: str | None,
|
|
144
|
+
) -> tuple[Path, str, Path, Path]:
|
|
145
|
+
"""(source, language, reference_path, config_dir), with `.cdec/` filling gaps.
|
|
146
|
+
|
|
147
|
+
Mirrors the CLI's `_resolve_reference_inputs` so a tool call and the
|
|
148
|
+
equivalent `cdec` invocation see identical inputs.
|
|
149
|
+
"""
|
|
150
|
+
from code_constraints.cli.detect import detect_language
|
|
151
|
+
|
|
152
|
+
cfg_dir = roots.config_dir(config_dir)
|
|
153
|
+
cfg = None
|
|
154
|
+
try:
|
|
155
|
+
cfg = load_project_config(cfg_dir)
|
|
156
|
+
except ConfigError:
|
|
157
|
+
cfg = None
|
|
158
|
+
|
|
159
|
+
source_path = roots.path(source) or (cfg.source if cfg else None)
|
|
160
|
+
if source_path is None:
|
|
161
|
+
raise ToolError(
|
|
162
|
+
f"no source given and none found in "
|
|
163
|
+
f"{_rel(roots, cfg_dir / RULES_FILENAME)}; pass `source`, or scaffold "
|
|
164
|
+
f"the project with `cdec init`."
|
|
165
|
+
)
|
|
166
|
+
if not source_path.is_dir():
|
|
167
|
+
raise ToolError(f"source is not a directory: {source_path}")
|
|
168
|
+
|
|
169
|
+
chosen = lang or (cfg.language if cfg else None) or detect_language(source_path)
|
|
170
|
+
if chosen is None:
|
|
171
|
+
raise ToolError(
|
|
172
|
+
f"could not determine a language for {_rel(roots, source_path)}; pass `lang`."
|
|
173
|
+
)
|
|
174
|
+
if chosen not in get_args(Language):
|
|
175
|
+
raise ToolError(f"unsupported language: {chosen}")
|
|
176
|
+
|
|
177
|
+
ref = roots.path(reference)
|
|
178
|
+
if ref is None:
|
|
179
|
+
ref = (cfg.reference if cfg and cfg.reference else cfg_dir / REFERENCE_FILENAME)
|
|
180
|
+
return source_path, chosen, ref, cfg_dir
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse(source: Path, lang: str) -> "Project":
|
|
184
|
+
from code_constraints.lint.pipeline import PipelineError, parse_source
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
return parse_source(source, lang)
|
|
188
|
+
except PipelineError as exc:
|
|
189
|
+
raise ToolError(str(exc)) from exc
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _load_model(path: Path) -> "Project":
|
|
193
|
+
from code_constraints.core.model_io import UnsupportedModelFormat, load_model
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
return load_model(path)
|
|
197
|
+
except UnsupportedModelFormat as exc:
|
|
198
|
+
raise ToolError(str(exc)) from exc
|
|
199
|
+
except Exception as exc: # noqa: BLE001 - surfaced verbatim
|
|
200
|
+
raise ToolError(f"could not read model {path}: {exc}") from exc
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _count_classes(project: "Project") -> int:
|
|
204
|
+
"""How many classes a model holds — the one number worth echoing back after
|
|
205
|
+
a write, so a caller can tell an empty parse from a real one.
|
|
206
|
+
|
|
207
|
+
`iter_classes` is untyped, so count through a list rather than `sum` over a
|
|
208
|
+
generator — mypy resolves the latter to the `Iterable[bool]` overload.
|
|
209
|
+
"""
|
|
210
|
+
return len(list(project.iter_classes())) # type: ignore[no-untyped-call]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _save_model(project: "Project", path: Path) -> None:
|
|
214
|
+
from code_constraints.core.model_io import UnsupportedModelFormat, save_model
|
|
215
|
+
|
|
216
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
try:
|
|
218
|
+
save_model(project, path)
|
|
219
|
+
except UnsupportedModelFormat as exc:
|
|
220
|
+
raise ToolError(str(exc)) from exc
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _project(roots: _Roots, config_dir: str | None) -> tuple[Path, "ProjectConfig", "LoadedRules"]:
|
|
224
|
+
"""(config_dir, settings, rules) — everything read out of `.cdec/rules.yaml`."""
|
|
225
|
+
cfg_dir = roots.config_dir(config_dir)
|
|
226
|
+
try:
|
|
227
|
+
return cfg_dir, load_project_config(cfg_dir), load_rules(cfg_dir)
|
|
228
|
+
except ConfigError as exc:
|
|
229
|
+
raise ToolError(str(exc)) from exc
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _source_context(roots: _Roots, cfg_dir: Path, cfg: "ProjectConfig", source: Path,
|
|
233
|
+
reference: Path | None) -> Any:
|
|
234
|
+
from code_constraints.lint.engine import SourceContext
|
|
235
|
+
|
|
236
|
+
return SourceContext(
|
|
237
|
+
source=source,
|
|
238
|
+
language=cfg.language,
|
|
239
|
+
config_dir=cfg_dir,
|
|
240
|
+
reference_path=reference or cfg.reference_path,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _collect(
|
|
245
|
+
roots: _Roots, config_dir: str | None, **opts: Any
|
|
246
|
+
) -> tuple[Path, "Collected"]:
|
|
247
|
+
"""Run every rule and return the keyed `Collected` issue list."""
|
|
248
|
+
from code_constraints.lint.pipeline import PipelineError
|
|
249
|
+
from code_constraints.waivers import CollectOptions, collect_issues
|
|
250
|
+
from code_constraints.waivers.store import WaiverFileError
|
|
251
|
+
|
|
252
|
+
cfg_dir = roots.config_dir(config_dir)
|
|
253
|
+
try:
|
|
254
|
+
return cfg_dir, collect_issues(
|
|
255
|
+
cfg_dir,
|
|
256
|
+
CollectOptions(
|
|
257
|
+
source=roots.path(opts.get("source")),
|
|
258
|
+
reference=roots.path(opts.get("reference")),
|
|
259
|
+
base_ref=opts.get("base_ref"),
|
|
260
|
+
repo=roots.path(opts.get("repo")) or roots.project_root,
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
except (ConfigError, PipelineError, WaiverFileError) as exc:
|
|
264
|
+
raise ToolError(str(exc)) from exc
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _issue_json(issue: "Issue") -> dict[str, Any]:
|
|
268
|
+
return {
|
|
269
|
+
"key": issue.key,
|
|
270
|
+
"engine": issue.engine,
|
|
271
|
+
"rule": issue.rule,
|
|
272
|
+
"rule_id": issue.rule_id,
|
|
273
|
+
"qualified_name": issue.qualified_name,
|
|
274
|
+
"detail": issue.detail,
|
|
275
|
+
"message": issue.message,
|
|
276
|
+
"severity": issue.severity,
|
|
277
|
+
"file": issue.file,
|
|
278
|
+
"line": issue.line,
|
|
279
|
+
"waived": issue.waived,
|
|
280
|
+
"waiver_reason": issue.waiver_reason,
|
|
281
|
+
"waivable": issue.waivable,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _apply_result_json(result: "ApplyResult") -> dict[str, Any]:
|
|
286
|
+
return {
|
|
287
|
+
"allowed": [_issue_json(i) for i in result.allowed],
|
|
288
|
+
"already_waived": [i.key for i in result.already_waived],
|
|
289
|
+
"removed": [w.key for w in result.removed],
|
|
290
|
+
"unknown": list(result.unknown),
|
|
291
|
+
"refused": [{"key": k, "reason": why} for k, why in result.refused],
|
|
292
|
+
"not_waived": list(result.not_waived),
|
|
293
|
+
"malformed": list(result.malformed),
|
|
294
|
+
"problems": list(result.problems),
|
|
295
|
+
"changed": result.changed,
|
|
296
|
+
"failed": result.failed,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --------------------------------------------------------------------------
|
|
301
|
+
# server construction
|
|
302
|
+
# --------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
def build_server(project_root: Path | None = None) -> MCPServer:
|
|
305
|
+
"""Build the code-constraints MCP server.
|
|
306
|
+
|
|
307
|
+
`project_root` anchors every relative path a tool receives. It defaults to
|
|
308
|
+
`$CDEC_PROJECT_ROOT` and then to the process working directory, which is
|
|
309
|
+
what most harnesses set to the open workspace.
|
|
310
|
+
"""
|
|
311
|
+
root = (
|
|
312
|
+
project_root
|
|
313
|
+
or (Path(os.environ["CDEC_PROJECT_ROOT"]) if os.environ.get("CDEC_PROJECT_ROOT") else None)
|
|
314
|
+
or Path.cwd()
|
|
315
|
+
).expanduser().resolve()
|
|
316
|
+
roots = _Roots(project_root=root)
|
|
317
|
+
|
|
318
|
+
mcp = MCPServer(
|
|
319
|
+
name="code-constraints",
|
|
320
|
+
title="code-constraints",
|
|
321
|
+
version=_package_version(),
|
|
322
|
+
instructions=SERVER_INSTRUCTIONS,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------- context
|
|
326
|
+
|
|
327
|
+
@mcp.tool()
|
|
328
|
+
def cdec_status(config_dir: Optional[str] = None) -> dict[str, Any]:
|
|
329
|
+
"""Show how code-constraints is configured for this project.
|
|
330
|
+
|
|
331
|
+
Start here when you don't know whether a repo uses cdec, which rules are
|
|
332
|
+
active, or where its reference model lives.
|
|
333
|
+
"""
|
|
334
|
+
cfg_dir = roots.config_dir(config_dir)
|
|
335
|
+
rules_file = cfg_dir / RULES_FILENAME
|
|
336
|
+
out: dict[str, Any] = {
|
|
337
|
+
"project_root": str(roots.project_root),
|
|
338
|
+
"config_dir": _rel(roots, cfg_dir),
|
|
339
|
+
"rules_file": _rel(roots, rules_file),
|
|
340
|
+
"configured": False,
|
|
341
|
+
}
|
|
342
|
+
try:
|
|
343
|
+
cfg = load_project_config(cfg_dir)
|
|
344
|
+
except ConfigError as exc:
|
|
345
|
+
out["error"] = str(exc)
|
|
346
|
+
out["hint"] = "Run `cdec init` in the project to scaffold `.cdec/rules.yaml`."
|
|
347
|
+
return out
|
|
348
|
+
|
|
349
|
+
ref = cfg.reference_path
|
|
350
|
+
rules: list[dict[str, Any]] = []
|
|
351
|
+
rules_error = None
|
|
352
|
+
try:
|
|
353
|
+
for rule in load_rules(cfg_dir).rules:
|
|
354
|
+
rules.append(
|
|
355
|
+
{"id": rule.rule_id, "type": rule.type_name,
|
|
356
|
+
"severity": rule.severity.value, "scope": rule.scope}
|
|
357
|
+
)
|
|
358
|
+
except ConfigError as exc:
|
|
359
|
+
rules_error = str(exc)
|
|
360
|
+
|
|
361
|
+
n_exceptions = 0
|
|
362
|
+
try:
|
|
363
|
+
from code_constraints.waivers import load_waivers
|
|
364
|
+
|
|
365
|
+
n_exceptions = len(load_waivers(cfg_dir).waivers)
|
|
366
|
+
except Exception as exc: # noqa: BLE001 - reported, not fatal
|
|
367
|
+
out["exceptions_error"] = str(exc)
|
|
368
|
+
|
|
369
|
+
n_locks = 0
|
|
370
|
+
try:
|
|
371
|
+
from code_constraints.lock import load_locks
|
|
372
|
+
|
|
373
|
+
n_locks = len(load_locks(cfg_dir))
|
|
374
|
+
except Exception as exc: # noqa: BLE001 - reported, not fatal
|
|
375
|
+
out["locks_error"] = str(exc)
|
|
376
|
+
|
|
377
|
+
stale = [_rel(roots, path) for path in legacy_files(cfg_dir)]
|
|
378
|
+
out.update(
|
|
379
|
+
{
|
|
380
|
+
"configured": True,
|
|
381
|
+
"language": cfg.language,
|
|
382
|
+
"source": _rel(roots, cfg.source),
|
|
383
|
+
"reference": {"path": _rel(roots, ref), "exists": ref.is_file()},
|
|
384
|
+
"rules": rules,
|
|
385
|
+
"rules_error": rules_error,
|
|
386
|
+
"exceptions": n_exceptions,
|
|
387
|
+
"locks": n_locks,
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
if stale:
|
|
391
|
+
out["legacy_files"] = stale
|
|
392
|
+
out["legacy_hint"] = (
|
|
393
|
+
"These per-concern files are superseded by rules.yaml. They are still "
|
|
394
|
+
"read; fold them in with `cdec init --migrate`."
|
|
395
|
+
)
|
|
396
|
+
return out
|
|
397
|
+
|
|
398
|
+
@mcp.tool()
|
|
399
|
+
def cdec_rules() -> dict[str, Any]:
|
|
400
|
+
"""List the constraint tags that can be written on code.
|
|
401
|
+
|
|
402
|
+
These are the decorators/attributes/macros (`@no_instantiation`,
|
|
403
|
+
`[Sealed]`, `---@cdec layer(...)`, …) that the `tag-conformance`,
|
|
404
|
+
`frozen-rules`, `layer-dependencies` and `implementation-locks` rules
|
|
405
|
+
read. Use this before adding a tag so you use the real name, its legal
|
|
406
|
+
targets, and its parameters.
|
|
407
|
+
"""
|
|
408
|
+
from code_constraints.core.rules import (
|
|
409
|
+
CSHARP_SHIM_NAMESPACE,
|
|
410
|
+
PYTHON_SHIM_MODULES,
|
|
411
|
+
RULE_CATALOG,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
"shims": {
|
|
416
|
+
"python_import": sorted(PYTHON_SHIM_MODULES),
|
|
417
|
+
"csharp_using": CSHARP_SHIM_NAMESPACE,
|
|
418
|
+
"note": (
|
|
419
|
+
"A tag is only recognised when imported from the shim namespace, "
|
|
420
|
+
"so unrelated decorators never false-match."
|
|
421
|
+
),
|
|
422
|
+
},
|
|
423
|
+
"rules": [
|
|
424
|
+
{
|
|
425
|
+
"id": spec.id,
|
|
426
|
+
"python": f"@{spec.python_name}",
|
|
427
|
+
"csharp": f"[{spec.csharp_name}]",
|
|
428
|
+
"targets": sorted(spec.targets),
|
|
429
|
+
"params": list(spec.params),
|
|
430
|
+
"enforcement": spec.enforcement,
|
|
431
|
+
"summary": spec.summary,
|
|
432
|
+
}
|
|
433
|
+
for spec in RULE_CATALOG.values()
|
|
434
|
+
],
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
@mcp.tool()
|
|
438
|
+
def cdec_rule_types() -> dict[str, Any]:
|
|
439
|
+
"""List the rule types that can appear in `.cdec/rules.yaml`.
|
|
440
|
+
|
|
441
|
+
Use this before writing a rule so you use a `type:` that exists. Each
|
|
442
|
+
entry says which key prefix its violations carry and whether it can
|
|
443
|
+
record a baseline of its own.
|
|
444
|
+
"""
|
|
445
|
+
from code_constraints.lint.rules import get_rule_class, known_rule_types
|
|
446
|
+
|
|
447
|
+
out = []
|
|
448
|
+
for name in known_rule_types():
|
|
449
|
+
cls = get_rule_class(name)
|
|
450
|
+
assert cls is not None
|
|
451
|
+
out.append(
|
|
452
|
+
{
|
|
453
|
+
"type": name,
|
|
454
|
+
"default_scope": cls.default_scope,
|
|
455
|
+
"reads_source": name in (
|
|
456
|
+
"tag-conformance", "implementation-locks", "reference-architecture"
|
|
457
|
+
),
|
|
458
|
+
"baselines_itself": cls.supports_auto_accept,
|
|
459
|
+
"summary": (cls.__doc__ or "").strip().splitlines()[0] if cls.__doc__ else "",
|
|
460
|
+
}
|
|
461
|
+
)
|
|
462
|
+
return {"rule_types": out}
|
|
463
|
+
|
|
464
|
+
# ------------------------------------------------------------- the gate
|
|
465
|
+
|
|
466
|
+
@mcp.tool()
|
|
467
|
+
def cdec_check(
|
|
468
|
+
config_dir: Optional[str] = None,
|
|
469
|
+
source: Optional[str] = None,
|
|
470
|
+
reference: Optional[str] = None,
|
|
471
|
+
base_ref: Optional[str] = None,
|
|
472
|
+
repo: Optional[str] = None,
|
|
473
|
+
fail_on: Literal["error", "warning", "none"] = "error",
|
|
474
|
+
bypass_locks: bool = False,
|
|
475
|
+
bypass_reason: str = "",
|
|
476
|
+
) -> dict[str, Any]:
|
|
477
|
+
"""Check the project against every rule in `.cdec/rules.yaml`.
|
|
478
|
+
|
|
479
|
+
This is the whole gate — configured architectural rules, source-tag
|
|
480
|
+
conformance, implementation locks and the reference-architecture check
|
|
481
|
+
all run here, because they are all rule types in that one file.
|
|
482
|
+
|
|
483
|
+
`base_ref` diffs against a git revision instead of the reference model;
|
|
484
|
+
`scope: diff` rules are skipped (and named in `skipped`) when neither
|
|
485
|
+
baseline is available, so a rule that could not run never looks like one
|
|
486
|
+
that passed.
|
|
487
|
+
|
|
488
|
+
`ok` is the pass/fail verdict. Every violation carries a `key` that
|
|
489
|
+
`cdec_allow` accepts — except lock violations, which are not acceptable
|
|
490
|
+
that way.
|
|
491
|
+
"""
|
|
492
|
+
from code_constraints.lint.baseline import load_baseline
|
|
493
|
+
from code_constraints.lint.engine import run_checks
|
|
494
|
+
from code_constraints.lint.pipeline import PipelineError, resolve_baseline
|
|
495
|
+
from code_constraints.lint.rules.base import Severity
|
|
496
|
+
|
|
497
|
+
cfg_dir, cfg, loaded = _project(roots, config_dir)
|
|
498
|
+
source_path = roots.path(source) or cfg.source
|
|
499
|
+
ref_path = roots.path(reference)
|
|
500
|
+
head = _parse(source_path, cfg.language)
|
|
501
|
+
try:
|
|
502
|
+
annotated, has_diff, base_proj = resolve_baseline(
|
|
503
|
+
head_proj=head,
|
|
504
|
+
lang=cfg.language,
|
|
505
|
+
config_dir=cfg_dir,
|
|
506
|
+
explicit_reference=ref_path,
|
|
507
|
+
explicit_base_ref=base_ref,
|
|
508
|
+
repo_path=roots.path(repo) or roots.project_root,
|
|
509
|
+
default_reference=cfg.reference,
|
|
510
|
+
)
|
|
511
|
+
except PipelineError as exc:
|
|
512
|
+
raise ToolError(str(exc)) from exc
|
|
513
|
+
|
|
514
|
+
report = run_checks(
|
|
515
|
+
annotated,
|
|
516
|
+
loaded.rules,
|
|
517
|
+
has_diff=has_diff,
|
|
518
|
+
baseline=load_baseline(cfg_dir),
|
|
519
|
+
baseline_project=base_proj,
|
|
520
|
+
source_context=_source_context(roots, cfg_dir, cfg, source_path, ref_path),
|
|
521
|
+
bypass_locks=bypass_locks,
|
|
522
|
+
bypass_reason=bypass_reason,
|
|
523
|
+
)
|
|
524
|
+
payload = report.to_json()
|
|
525
|
+
payload.update(
|
|
526
|
+
{
|
|
527
|
+
"ok": not report.has_failures(Severity(fail_on)),
|
|
528
|
+
"source": _rel(roots, source_path),
|
|
529
|
+
"language": cfg.language,
|
|
530
|
+
"has_baseline": has_diff,
|
|
531
|
+
"rules_file": _rel(roots, cfg_dir / RULES_FILENAME),
|
|
532
|
+
"text": report.to_human(),
|
|
533
|
+
}
|
|
534
|
+
)
|
|
535
|
+
return payload
|
|
536
|
+
|
|
537
|
+
@mcp.tool()
|
|
538
|
+
def cdec_accept(
|
|
539
|
+
what: list[Literal["rules", "locks", "reference"]],
|
|
540
|
+
config_dir: Optional[str] = None,
|
|
541
|
+
source: Optional[str] = None,
|
|
542
|
+
reference: Optional[str] = None,
|
|
543
|
+
base_ref: Optional[str] = None,
|
|
544
|
+
repo: Optional[str] = None,
|
|
545
|
+
force: bool = False,
|
|
546
|
+
) -> dict[str, Any]:
|
|
547
|
+
"""Record the code as it stands now as approved, instead of failing on it.
|
|
548
|
+
|
|
549
|
+
The counterpart of `cdec check --automatic-exceptions`. Ask the user
|
|
550
|
+
before calling any of these — each one switches off detection the
|
|
551
|
+
project asked for:
|
|
552
|
+
|
|
553
|
+
"rules" grandfather every current violation into `exceptions:`, so
|
|
554
|
+
only NEW ones fail. The adoption move on an existing
|
|
555
|
+
codebase.
|
|
556
|
+
"locks" record digests for newly @locked elements. Safe on its own:
|
|
557
|
+
without `force` it can only ADD locks, never erase evidence
|
|
558
|
+
that a frozen body changed. With `force=True` it ACCEPTS a
|
|
559
|
+
change to frozen code — only ever with explicit approval.
|
|
560
|
+
"reference" re-snapshot .cdec/reference.xmi from the current source,
|
|
561
|
+
which erases the drift the reference existed to detect.
|
|
562
|
+
|
|
563
|
+
Order is fixed: the reference and the locks settle first, then the checks
|
|
564
|
+
re-run, and only what is still reported gets grandfathered — otherwise
|
|
565
|
+
you would write exceptions for issues the re-snapshot was about to erase.
|
|
566
|
+
"""
|
|
567
|
+
from code_constraints.core.model import Project
|
|
568
|
+
from code_constraints.lint.baseline import write_baseline
|
|
569
|
+
from code_constraints.lint.engine import run_checks
|
|
570
|
+
from code_constraints.lint.pipeline import PipelineError, resolve_baseline
|
|
571
|
+
from code_constraints.lint.rules.base import RuleContext, RuleSkipped
|
|
572
|
+
|
|
573
|
+
accept = set(what)
|
|
574
|
+
if not accept:
|
|
575
|
+
raise ToolError("`what` must name at least one of: rules, locks, reference")
|
|
576
|
+
cfg_dir, cfg, loaded = _project(roots, config_dir)
|
|
577
|
+
source_path = roots.path(source) or cfg.source
|
|
578
|
+
ref_path = roots.path(reference)
|
|
579
|
+
ctx_info = _source_context(roots, cfg_dir, cfg, source_path, ref_path)
|
|
580
|
+
|
|
581
|
+
out: dict[str, Any] = {"ok": True, "recorded": {}, "skipped": []}
|
|
582
|
+
auto_ctx = RuleContext(
|
|
583
|
+
# `load_project_config` already validated the language against
|
|
584
|
+
# SUPPORTED_LANGUAGES, so this narrows rather than re-checks.
|
|
585
|
+
project=Project(source_language=cast(Language, cfg.language)),
|
|
586
|
+
has_diff=False,
|
|
587
|
+
source=ctx_info.source,
|
|
588
|
+
language=ctx_info.language,
|
|
589
|
+
config_dir=ctx_info.config_dir,
|
|
590
|
+
reference_path=ctx_info.reference_path,
|
|
591
|
+
)
|
|
592
|
+
for name, type_name in (
|
|
593
|
+
("reference", "reference-architecture"),
|
|
594
|
+
("locks", "implementation-locks"),
|
|
595
|
+
):
|
|
596
|
+
if name not in accept:
|
|
597
|
+
continue
|
|
598
|
+
rules = loaded.of_type(type_name)
|
|
599
|
+
if not rules:
|
|
600
|
+
out["skipped"].append(
|
|
601
|
+
{"what": name, "reason": f"no `{type_name}` rule in rules.yaml"}
|
|
602
|
+
)
|
|
603
|
+
continue
|
|
604
|
+
lines: list[str] = []
|
|
605
|
+
for rule in rules:
|
|
606
|
+
try:
|
|
607
|
+
lines.extend(rule.accept_current_state(auto_ctx, force=force))
|
|
608
|
+
except RuleSkipped as exc:
|
|
609
|
+
out["skipped"].append({"what": name, "reason": str(exc)})
|
|
610
|
+
out["recorded"][name] = lines
|
|
611
|
+
|
|
612
|
+
if "rules" in accept:
|
|
613
|
+
head = _parse(source_path, cfg.language)
|
|
614
|
+
try:
|
|
615
|
+
annotated, has_diff, base_proj = resolve_baseline(
|
|
616
|
+
head_proj=head,
|
|
617
|
+
lang=cfg.language,
|
|
618
|
+
config_dir=cfg_dir,
|
|
619
|
+
explicit_reference=ref_path,
|
|
620
|
+
explicit_base_ref=base_ref,
|
|
621
|
+
repo_path=roots.path(repo) or roots.project_root,
|
|
622
|
+
default_reference=cfg.reference,
|
|
623
|
+
)
|
|
624
|
+
except PipelineError as exc:
|
|
625
|
+
raise ToolError(str(exc)) from exc
|
|
626
|
+
report = run_checks(
|
|
627
|
+
annotated,
|
|
628
|
+
loaded.rules,
|
|
629
|
+
has_diff=has_diff,
|
|
630
|
+
baseline=None,
|
|
631
|
+
baseline_project=base_proj,
|
|
632
|
+
source_context=ctx_info,
|
|
633
|
+
)
|
|
634
|
+
grandfathered = [v for v in report.violations if v.waivable]
|
|
635
|
+
write_baseline(cfg_dir, grandfathered)
|
|
636
|
+
out["recorded"]["rules"] = [
|
|
637
|
+
{"key": v.key(), "rule_id": v.rule_id, "qualified_name": v.qualified_name}
|
|
638
|
+
for v in grandfathered
|
|
639
|
+
]
|
|
640
|
+
refused = [v for v in report.violations if not v.waivable]
|
|
641
|
+
if refused:
|
|
642
|
+
out["not_grandfathered"] = [
|
|
643
|
+
{"key": v.key(), "qualified_name": v.qualified_name} for v in refused
|
|
644
|
+
]
|
|
645
|
+
out["hint"] = (
|
|
646
|
+
"Lock violations are never grandfathered as exceptions. Accept one "
|
|
647
|
+
"with cdec_accept(what=['locks'], force=True), and only with the "
|
|
648
|
+
"user's approval."
|
|
649
|
+
)
|
|
650
|
+
out["rules_file"] = _rel(roots, cfg_dir / RULES_FILENAME)
|
|
651
|
+
return out
|
|
652
|
+
|
|
653
|
+
@mcp.tool()
|
|
654
|
+
def cdec_locks(
|
|
655
|
+
source: Optional[str] = None,
|
|
656
|
+
lang: Optional[str] = None,
|
|
657
|
+
config_dir: Optional[str] = None,
|
|
658
|
+
) -> dict[str, Any]:
|
|
659
|
+
"""List which elements are lockable, which are tagged, and which are frozen.
|
|
660
|
+
|
|
661
|
+
Use it to see what a `@locked` / `[Locked]` tag would cover before you
|
|
662
|
+
write one, and to spot tags that have never been recorded in the ledger.
|
|
663
|
+
Verifying the locks is `cdec_check`; recording them is
|
|
664
|
+
`cdec_accept(what=["locks"])`.
|
|
665
|
+
"""
|
|
666
|
+
from code_constraints.lock import (
|
|
667
|
+
LockOptions,
|
|
668
|
+
UnsupportedLockLanguage,
|
|
669
|
+
collect_targets,
|
|
670
|
+
is_locked_target,
|
|
671
|
+
load_locks,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
source_path, chosen, _ref, cfg_dir = _resolve_inputs(
|
|
675
|
+
roots, source, lang, None, config_dir
|
|
676
|
+
)
|
|
677
|
+
from code_constraints.lint.rules.implementation_locks import ImplementationLocks
|
|
678
|
+
|
|
679
|
+
options = LockOptions()
|
|
680
|
+
try:
|
|
681
|
+
loaded = load_rules(cfg_dir)
|
|
682
|
+
except ConfigError:
|
|
683
|
+
loaded = None
|
|
684
|
+
if loaded is not None:
|
|
685
|
+
for rule in loaded.of_type("implementation-locks"):
|
|
686
|
+
# `of_type` matches on the registered name, so this always holds;
|
|
687
|
+
# the isinstance is what tells the type checker so.
|
|
688
|
+
if isinstance(rule, ImplementationLocks):
|
|
689
|
+
options = rule.lock_options()
|
|
690
|
+
break
|
|
691
|
+
entries = load_locks(cfg_dir)
|
|
692
|
+
try:
|
|
693
|
+
targets = collect_targets(source_path, chosen, options)
|
|
694
|
+
except UnsupportedLockLanguage as exc:
|
|
695
|
+
raise ToolError(str(exc)) from exc
|
|
696
|
+
|
|
697
|
+
rows = []
|
|
698
|
+
for t in targets:
|
|
699
|
+
entry = entries.get(t.target)
|
|
700
|
+
rows.append(
|
|
701
|
+
{
|
|
702
|
+
"target": t.target,
|
|
703
|
+
"kind": t.kind,
|
|
704
|
+
"file": t.file,
|
|
705
|
+
"line": t.line,
|
|
706
|
+
"tagged": is_locked_target(t, options.patterns),
|
|
707
|
+
"baselined": entry is not None,
|
|
708
|
+
"digest": entry.digest if entry else None,
|
|
709
|
+
}
|
|
710
|
+
)
|
|
711
|
+
stale = [name for name in entries if name not in {t.target for t in targets}]
|
|
712
|
+
return {
|
|
713
|
+
"rules_file": _rel(roots, cfg_dir / RULES_FILENAME),
|
|
714
|
+
"rule_configured": bool(loaded and loaded.of_type("implementation-locks")),
|
|
715
|
+
"targets": rows,
|
|
716
|
+
"stale_entries": stale,
|
|
717
|
+
"summary": {
|
|
718
|
+
"lockable": len(rows),
|
|
719
|
+
"tagged": sum(1 for r in rows if r["tagged"]),
|
|
720
|
+
"baselined": sum(1 for r in rows if r["baselined"]),
|
|
721
|
+
"stale": len(stale),
|
|
722
|
+
},
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
# ------------------------------------------------------- the review loop
|
|
726
|
+
|
|
727
|
+
@mcp.tool()
|
|
728
|
+
def cdec_issues(
|
|
729
|
+
config_dir: Optional[str] = None,
|
|
730
|
+
source: Optional[str] = None,
|
|
731
|
+
reference: Optional[str] = None,
|
|
732
|
+
base_ref: Optional[str] = None,
|
|
733
|
+
repo: Optional[str] = None,
|
|
734
|
+
include_waived: bool = False,
|
|
735
|
+
engine: Optional[Literal["check", "enforce", "lock", "reference"]] = None,
|
|
736
|
+
rule_id: Optional[str] = None,
|
|
737
|
+
) -> dict[str, Any]:
|
|
738
|
+
"""Every issue `cdec check` reports right now, as one keyed list.
|
|
739
|
+
|
|
740
|
+
The triage view: one flat list with a stable `key` per issue, so you can
|
|
741
|
+
hand specific keys to `cdec_allow`. Filter by `engine` (the key prefix's
|
|
742
|
+
family) or by `rule_id` (the `rules.yaml` entry). Already-accepted issues
|
|
743
|
+
are excluded unless `include_waived=True` (needed to withdraw one).
|
|
744
|
+
`skipped` names rules that could not run — a skipped rule looks exactly
|
|
745
|
+
like a clean one otherwise.
|
|
746
|
+
"""
|
|
747
|
+
_cfg_dir, collected = _collect(
|
|
748
|
+
roots,
|
|
749
|
+
config_dir,
|
|
750
|
+
source=source,
|
|
751
|
+
reference=reference,
|
|
752
|
+
base_ref=base_ref,
|
|
753
|
+
repo=repo,
|
|
754
|
+
)
|
|
755
|
+
issues = [
|
|
756
|
+
i
|
|
757
|
+
for i in collected.issues
|
|
758
|
+
if (include_waived or not i.waived)
|
|
759
|
+
and (engine is None or i.engine == engine)
|
|
760
|
+
and (rule_id is None or i.rule_id == rule_id)
|
|
761
|
+
]
|
|
762
|
+
return {
|
|
763
|
+
"ok": not [i for i in issues if not i.waived],
|
|
764
|
+
"rules_file": _rel(roots, collected.ledger_path),
|
|
765
|
+
"rules_ran": sorted(collected.rules_ran),
|
|
766
|
+
"engines_ran": sorted(collected.engines_ran),
|
|
767
|
+
"skipped": [{"rule_id": r, "reason": why} for r, why in collected.skipped],
|
|
768
|
+
"issues": [_issue_json(i) for i in issues],
|
|
769
|
+
"summary": {
|
|
770
|
+
"total": len(issues),
|
|
771
|
+
"open": sum(1 for i in issues if not i.waived),
|
|
772
|
+
"waived": sum(1 for i in issues if i.waived),
|
|
773
|
+
"by_engine": {
|
|
774
|
+
name: sum(1 for i in issues if i.engine == name)
|
|
775
|
+
for name in ("check", "enforce", "lock", "reference")
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
@mcp.tool()
|
|
781
|
+
def cdec_allow(
|
|
782
|
+
keys: list[str],
|
|
783
|
+
reason: str = "",
|
|
784
|
+
config_dir: Optional[str] = None,
|
|
785
|
+
source: Optional[str] = None,
|
|
786
|
+
reference: Optional[str] = None,
|
|
787
|
+
base_ref: Optional[str] = None,
|
|
788
|
+
repo: Optional[str] = None,
|
|
789
|
+
dry_run: bool = False,
|
|
790
|
+
) -> dict[str, Any]:
|
|
791
|
+
"""Accept issues by key, recording it in the `exceptions:` section of
|
|
792
|
+
`.cdec/rules.yaml`.
|
|
793
|
+
|
|
794
|
+
Ask the user before accepting anything — this switches off a rule they
|
|
795
|
+
asked for. Always pass a `reason`; it is what makes the entry reviewable.
|
|
796
|
+
Keys must name issues reported right now, so a stale key is an error
|
|
797
|
+
rather than a silent no-op.
|
|
798
|
+
|
|
799
|
+
Lock issues (`L-…`) are refused by design: use
|
|
800
|
+
`cdec_accept(what=["locks"], force=True)`.
|
|
801
|
+
"""
|
|
802
|
+
from code_constraints.waivers import allow_keys, save_waivers
|
|
803
|
+
|
|
804
|
+
_cfg_dir, collected = _collect(
|
|
805
|
+
roots,
|
|
806
|
+
config_dir,
|
|
807
|
+
source=source,
|
|
808
|
+
reference=reference,
|
|
809
|
+
base_ref=base_ref,
|
|
810
|
+
repo=repo,
|
|
811
|
+
)
|
|
812
|
+
result = allow_keys(collected.store, collected, keys, reason=reason)
|
|
813
|
+
if result.changed and not dry_run:
|
|
814
|
+
save_waivers(_cfg_dir, collected.store)
|
|
815
|
+
|
|
816
|
+
payload = _apply_result_json(result)
|
|
817
|
+
payload.update(
|
|
818
|
+
{
|
|
819
|
+
"ok": not result.failed,
|
|
820
|
+
"rules_file": _rel(roots, collected.ledger_path),
|
|
821
|
+
"written": result.changed and not dry_run,
|
|
822
|
+
"dry_run": dry_run,
|
|
823
|
+
}
|
|
824
|
+
)
|
|
825
|
+
return payload
|
|
826
|
+
|
|
827
|
+
@mcp.tool()
|
|
828
|
+
def cdec_exceptions_list(
|
|
829
|
+
config_dir: Optional[str] = None,
|
|
830
|
+
engine: Optional[Literal["check", "enforce", "reference"]] = None,
|
|
831
|
+
) -> dict[str, Any]:
|
|
832
|
+
"""Show what is currently accepted as an exception, and why.
|
|
833
|
+
|
|
834
|
+
Needs no source parse, so it works even when the code doesn't parse.
|
|
835
|
+
"""
|
|
836
|
+
from code_constraints.waivers import load_waivers
|
|
837
|
+
from code_constraints.waivers.store import WaiverFileError
|
|
838
|
+
|
|
839
|
+
cfg_dir = roots.config_dir(config_dir)
|
|
840
|
+
try:
|
|
841
|
+
store = load_waivers(cfg_dir)
|
|
842
|
+
except WaiverFileError as exc:
|
|
843
|
+
raise ToolError(str(exc)) from exc
|
|
844
|
+
|
|
845
|
+
waivers = [w for w in store.waivers if engine is None or w.engine == engine]
|
|
846
|
+
return {
|
|
847
|
+
"rules_file": _rel(roots, cfg_dir / RULES_FILENAME),
|
|
848
|
+
"exceptions": [
|
|
849
|
+
{
|
|
850
|
+
"key": w.key,
|
|
851
|
+
"engine": w.engine,
|
|
852
|
+
"rule": w.rule,
|
|
853
|
+
"qualified_name": w.qualified_name,
|
|
854
|
+
"detail": w.detail,
|
|
855
|
+
"reason": w.reason,
|
|
856
|
+
"added": w.added,
|
|
857
|
+
"added_by": w.added_by,
|
|
858
|
+
}
|
|
859
|
+
for w in sorted(
|
|
860
|
+
waivers, key=lambda w: (w.engine, w.rule, w.qualified_name, w.detail)
|
|
861
|
+
)
|
|
862
|
+
],
|
|
863
|
+
"count": len(waivers),
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
@mcp.tool()
|
|
867
|
+
def cdec_exception_remove(
|
|
868
|
+
keys: list[str],
|
|
869
|
+
config_dir: Optional[str] = None,
|
|
870
|
+
dry_run: bool = False,
|
|
871
|
+
) -> dict[str, Any]:
|
|
872
|
+
"""Withdraw exceptions by key so those issues block again.
|
|
873
|
+
|
|
874
|
+
Needs no source parse — the ledger alone identifies what to drop, so an
|
|
875
|
+
exception can always be withdrawn even if the code no longer parses.
|
|
876
|
+
"""
|
|
877
|
+
from code_constraints.waivers import remove_keys, save_waivers
|
|
878
|
+
|
|
879
|
+
cfg_dir = roots.config_dir(config_dir)
|
|
880
|
+
store = _load_store(cfg_dir)
|
|
881
|
+
result = remove_keys(store, keys)
|
|
882
|
+
if result.changed and not dry_run:
|
|
883
|
+
save_waivers(cfg_dir, store)
|
|
884
|
+
|
|
885
|
+
payload = _apply_result_json(result)
|
|
886
|
+
payload.update(
|
|
887
|
+
{
|
|
888
|
+
"ok": not (result.malformed or result.not_waived),
|
|
889
|
+
"rules_file": _rel(roots, cfg_dir / RULES_FILENAME),
|
|
890
|
+
"written": result.changed and not dry_run,
|
|
891
|
+
"dry_run": dry_run,
|
|
892
|
+
}
|
|
893
|
+
)
|
|
894
|
+
return payload
|
|
895
|
+
|
|
896
|
+
@mcp.tool()
|
|
897
|
+
def cdec_exceptions_prune(
|
|
898
|
+
config_dir: Optional[str] = None,
|
|
899
|
+
source: Optional[str] = None,
|
|
900
|
+
reference: Optional[str] = None,
|
|
901
|
+
base_ref: Optional[str] = None,
|
|
902
|
+
repo: Optional[str] = None,
|
|
903
|
+
dry_run: bool = False,
|
|
904
|
+
) -> dict[str, Any]:
|
|
905
|
+
"""Drop exceptions for issues that no longer occur.
|
|
906
|
+
|
|
907
|
+
A stale exception silently pre-approves a future violation of the same
|
|
908
|
+
rule on the same element. Only prunes engines whose rules actually ran
|
|
909
|
+
this time, so a skipped rule never looks like a clean one.
|
|
910
|
+
"""
|
|
911
|
+
from code_constraints.waivers import prune as prune_waivers
|
|
912
|
+
from code_constraints.waivers import save_waivers
|
|
913
|
+
|
|
914
|
+
_cfg_dir, collected = _collect(
|
|
915
|
+
roots,
|
|
916
|
+
config_dir,
|
|
917
|
+
source=source,
|
|
918
|
+
reference=reference,
|
|
919
|
+
base_ref=base_ref,
|
|
920
|
+
repo=repo,
|
|
921
|
+
)
|
|
922
|
+
stale = prune_waivers(collected.store, collected)
|
|
923
|
+
if stale and not dry_run:
|
|
924
|
+
save_waivers(_cfg_dir, collected.store)
|
|
925
|
+
return {
|
|
926
|
+
"ok": True,
|
|
927
|
+
"rules_file": _rel(roots, collected.ledger_path),
|
|
928
|
+
"written": bool(stale) and not dry_run,
|
|
929
|
+
"dry_run": dry_run,
|
|
930
|
+
"pruned": [
|
|
931
|
+
{
|
|
932
|
+
"key": w.key,
|
|
933
|
+
"engine": w.engine,
|
|
934
|
+
"rule": w.rule,
|
|
935
|
+
"qualified_name": w.qualified_name,
|
|
936
|
+
"detail": w.detail,
|
|
937
|
+
}
|
|
938
|
+
for w in stale
|
|
939
|
+
],
|
|
940
|
+
"engines_ran": sorted(collected.engines_ran),
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
# -------------------------------------------------------- model pipeline
|
|
944
|
+
|
|
945
|
+
@mcp.tool()
|
|
946
|
+
def cdec_parse(
|
|
947
|
+
out: str,
|
|
948
|
+
source: Optional[str] = None,
|
|
949
|
+
lang: Optional[str] = None,
|
|
950
|
+
config_dir: Optional[str] = None,
|
|
951
|
+
) -> dict[str, Any]:
|
|
952
|
+
"""Parse a source tree into a model file (`.xmi` or `.json`).
|
|
953
|
+
|
|
954
|
+
The extension of `out` picks the format: `.json` is the hand- and
|
|
955
|
+
agent-editable editor shape, `.xmi` is the on-disk source of truth.
|
|
956
|
+
"""
|
|
957
|
+
source_path, chosen, _ref, _cfg = _resolve_inputs(
|
|
958
|
+
roots, source, lang, None, config_dir
|
|
959
|
+
)
|
|
960
|
+
out_path = roots.path(out)
|
|
961
|
+
assert out_path is not None
|
|
962
|
+
project = _parse(source_path, chosen)
|
|
963
|
+
_save_model(project, out_path)
|
|
964
|
+
return {
|
|
965
|
+
"ok": True,
|
|
966
|
+
"wrote": _rel(roots, out_path),
|
|
967
|
+
"source": _rel(roots, source_path),
|
|
968
|
+
"language": chosen,
|
|
969
|
+
"classes": _count_classes(project),
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
@mcp.tool()
|
|
973
|
+
def cdec_convert(src: str, dest: str) -> dict[str, Any]:
|
|
974
|
+
"""Convert a model file between XMI 2.1 and editor JSON.
|
|
975
|
+
|
|
976
|
+
`cdec_convert("model.xmi", "model.json")` gives you an editable version
|
|
977
|
+
of a parsed architecture; converting back produces standard XMI again.
|
|
978
|
+
"""
|
|
979
|
+
src_path = roots.require_file(src, what="source model")
|
|
980
|
+
dest_path = roots.path(dest)
|
|
981
|
+
assert dest_path is not None
|
|
982
|
+
project = _load_model(src_path)
|
|
983
|
+
_save_model(project, dest_path)
|
|
984
|
+
return {
|
|
985
|
+
"ok": True,
|
|
986
|
+
"wrote": _rel(roots, dest_path),
|
|
987
|
+
"classes": _count_classes(project),
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
@mcp.tool()
|
|
991
|
+
def cdec_reference_set(
|
|
992
|
+
model: str,
|
|
993
|
+
reference: Optional[str] = None,
|
|
994
|
+
config_dir: Optional[str] = None,
|
|
995
|
+
) -> dict[str, Any]:
|
|
996
|
+
"""Promote an authored model file to be the project's target architecture.
|
|
997
|
+
|
|
998
|
+
This is the "accept the proposal" step: after the user agrees to a design
|
|
999
|
+
you proposed with `cdec_propose`, this writes it to the reference model,
|
|
1000
|
+
so `cdec_check` starts constraining development against it. Confirm with
|
|
1001
|
+
the user first — it changes what the whole project is gated on.
|
|
1002
|
+
|
|
1003
|
+
This declares what the code *should become*. To record what it *is*
|
|
1004
|
+
instead, use `cdec_accept(what=["reference"])`.
|
|
1005
|
+
"""
|
|
1006
|
+
model_path = roots.require_file(model, what="model file")
|
|
1007
|
+
cfg_dir = roots.config_dir(config_dir)
|
|
1008
|
+
ref_path = roots.path(reference)
|
|
1009
|
+
if ref_path is None:
|
|
1010
|
+
try:
|
|
1011
|
+
ref_path = load_project_config(cfg_dir).reference_path
|
|
1012
|
+
except ConfigError:
|
|
1013
|
+
ref_path = cfg_dir / REFERENCE_FILENAME
|
|
1014
|
+
|
|
1015
|
+
project = _load_model(model_path)
|
|
1016
|
+
_save_model(project, ref_path)
|
|
1017
|
+
return {
|
|
1018
|
+
"ok": True,
|
|
1019
|
+
"reference": _rel(roots, ref_path),
|
|
1020
|
+
"from": _rel(roots, model_path),
|
|
1021
|
+
"classes": _count_classes(project),
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
@mcp.tool()
|
|
1025
|
+
def cdec_propose(
|
|
1026
|
+
model: str,
|
|
1027
|
+
source: Optional[str] = None,
|
|
1028
|
+
lang: Optional[str] = None,
|
|
1029
|
+
config_dir: Optional[str] = None,
|
|
1030
|
+
against: Literal["source", "reference", "none"] = "source",
|
|
1031
|
+
focus: Optional[list[str]] = None,
|
|
1032
|
+
host: str = "127.0.0.1",
|
|
1033
|
+
port: int = 8765,
|
|
1034
|
+
start_viewer: bool = True,
|
|
1035
|
+
) -> dict[str, Any]:
|
|
1036
|
+
"""Show a proposed architecture in the browser, diffed against the code.
|
|
1037
|
+
|
|
1038
|
+
The design-review loop: write a model `.json`, propose it, and the user
|
|
1039
|
+
sees green for "still to build" and red for "to be removed". Re-proposing
|
|
1040
|
+
after an edit refreshes the already-open tab in place — no new tabs — so
|
|
1041
|
+
iterate by calling this repeatedly with the same `model`. `focus` pre-
|
|
1042
|
+
filters the diagram to the given qualified class names.
|
|
1043
|
+
|
|
1044
|
+
Returns the viewer URL; give it to the user so they can look at it. When
|
|
1045
|
+
the design is agreed, lock it with `cdec_reference_set`.
|
|
1046
|
+
"""
|
|
1047
|
+
from code_constraints.core.editor_io import project_to_json
|
|
1048
|
+
|
|
1049
|
+
model_path = roots.require_file(model, what="model file")
|
|
1050
|
+
source_path, chosen, ref_path, _cfg = _resolve_inputs(
|
|
1051
|
+
roots, source, lang, None, config_dir
|
|
1052
|
+
)
|
|
1053
|
+
proposal = _load_model(model_path)
|
|
1054
|
+
base_url = f"http://{host}:{port}"
|
|
1055
|
+
focus_list = [f.strip() for f in (focus or []) if f.strip()]
|
|
1056
|
+
focus_q = quote(",".join(focus_list)) if focus_list else ""
|
|
1057
|
+
|
|
1058
|
+
started = False
|
|
1059
|
+
if not _server_alive(base_url):
|
|
1060
|
+
if not start_viewer:
|
|
1061
|
+
raise ToolError(
|
|
1062
|
+
f"no code-constraints viewer is running on {base_url}. Start one with "
|
|
1063
|
+
"`cdec serve`, or call this tool again with start_viewer=True."
|
|
1064
|
+
)
|
|
1065
|
+
_spawn_viewer(host, port, roots.project_root)
|
|
1066
|
+
started = _wait_for_server(base_url, timeout=30.0)
|
|
1067
|
+
if not started:
|
|
1068
|
+
raise ToolError(
|
|
1069
|
+
f"started a viewer but {base_url} did not come up within 30s. "
|
|
1070
|
+
"Run `cdec serve` manually and retry."
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
if against == "reference" and not ref_path.is_file():
|
|
1074
|
+
raise ToolError(f"reference model not found: {_rel(roots, ref_path)}")
|
|
1075
|
+
|
|
1076
|
+
try:
|
|
1077
|
+
info = _http_json(
|
|
1078
|
+
"POST", f"{base_url}/api/projects", {"path": str(source_path), "lang": chosen}
|
|
1079
|
+
)
|
|
1080
|
+
result = _http_json(
|
|
1081
|
+
"POST",
|
|
1082
|
+
f"{base_url}/api/projects/{info['id']}/proposal"
|
|
1083
|
+
f"?against={against}&focus={focus_q}",
|
|
1084
|
+
project_to_json(proposal),
|
|
1085
|
+
)
|
|
1086
|
+
except RuntimeError as exc:
|
|
1087
|
+
raise ToolError(f"pushing the proposal failed: {exc}") from exc
|
|
1088
|
+
|
|
1089
|
+
url = (
|
|
1090
|
+
f"{base_url}/?xmi={result['id']}&project={result['project_id']}"
|
|
1091
|
+
f"&path={quote(f'proposal: {model_path.name}')}&lang={chosen}&proposal=1"
|
|
1092
|
+
+ (f"&focus={focus_q}" if focus_q else "")
|
|
1093
|
+
)
|
|
1094
|
+
return {
|
|
1095
|
+
"ok": True,
|
|
1096
|
+
"url": url,
|
|
1097
|
+
"seq": result["seq"],
|
|
1098
|
+
"against": against,
|
|
1099
|
+
"focus": focus_list,
|
|
1100
|
+
"viewer_started": started,
|
|
1101
|
+
"note": (
|
|
1102
|
+
"Share this URL with the user. Re-proposing the same model refreshes "
|
|
1103
|
+
"an open tab in place. When agreed, call cdec_reference_set."
|
|
1104
|
+
if result["seq"] == 1
|
|
1105
|
+
else "An open viewer tab refreshed automatically."
|
|
1106
|
+
),
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return mcp
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
# --------------------------------------------------------------------------
|
|
1113
|
+
# small stdlib helpers (kept module-level so they're unit-testable)
|
|
1114
|
+
# --------------------------------------------------------------------------
|
|
1115
|
+
|
|
1116
|
+
def _package_version() -> str:
|
|
1117
|
+
"""The installed distribution version, reported in the MCP handshake."""
|
|
1118
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
1119
|
+
|
|
1120
|
+
try:
|
|
1121
|
+
return version("code-constraints")
|
|
1122
|
+
except PackageNotFoundError: # running from a source tree, not installed
|
|
1123
|
+
return "0+unknown"
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _load_store(config_dir: Path) -> Any:
|
|
1127
|
+
from code_constraints.waivers import load_waivers
|
|
1128
|
+
from code_constraints.waivers.store import WaiverFileError
|
|
1129
|
+
|
|
1130
|
+
try:
|
|
1131
|
+
return load_waivers(config_dir)
|
|
1132
|
+
except WaiverFileError as exc:
|
|
1133
|
+
raise ToolError(str(exc)) from exc
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _server_alive(base_url: str) -> bool:
|
|
1137
|
+
"""True if a code-constraints viewer answers at `base_url`."""
|
|
1138
|
+
import urllib.error
|
|
1139
|
+
import urllib.request
|
|
1140
|
+
|
|
1141
|
+
try:
|
|
1142
|
+
with urllib.request.urlopen(f"{base_url}/api/projects", timeout=1.5):
|
|
1143
|
+
return True
|
|
1144
|
+
except (urllib.error.URLError, OSError, TimeoutError):
|
|
1145
|
+
return False
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _wait_for_server(base_url: str, timeout: float) -> bool:
|
|
1149
|
+
deadline = time.monotonic() + timeout
|
|
1150
|
+
while time.monotonic() < deadline:
|
|
1151
|
+
if _server_alive(base_url):
|
|
1152
|
+
return True
|
|
1153
|
+
time.sleep(0.5)
|
|
1154
|
+
return False
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _spawn_viewer(host: str, port: int, cwd: Path) -> None:
|
|
1158
|
+
"""Start `cdec serve` detached.
|
|
1159
|
+
|
|
1160
|
+
Detached matters twice over: the MCP server owns stdout as the protocol
|
|
1161
|
+
channel, so the child must never inherit it, and the viewer has to outlive
|
|
1162
|
+
the tool call that started it.
|
|
1163
|
+
"""
|
|
1164
|
+
kwargs: dict[str, Any] = {}
|
|
1165
|
+
if sys.platform == "win32":
|
|
1166
|
+
# getattr, not attribute access: these constants only exist on Windows,
|
|
1167
|
+
# so naming them directly fails type-checking on every other platform.
|
|
1168
|
+
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(
|
|
1169
|
+
subprocess, "DETACHED_PROCESS", 0
|
|
1170
|
+
)
|
|
1171
|
+
else:
|
|
1172
|
+
kwargs["start_new_session"] = True
|
|
1173
|
+
subprocess.Popen(
|
|
1174
|
+
[sys.executable, "-m", "code_constraints.cli", "serve", "--host", host, "--port", str(port)],
|
|
1175
|
+
cwd=str(cwd),
|
|
1176
|
+
stdin=subprocess.DEVNULL,
|
|
1177
|
+
stdout=subprocess.DEVNULL,
|
|
1178
|
+
stderr=subprocess.DEVNULL,
|
|
1179
|
+
**kwargs,
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _http_json(
|
|
1184
|
+
method: str, url: str, payload: dict[str, Any] | None = None
|
|
1185
|
+
) -> dict[str, Any]:
|
|
1186
|
+
"""Minimal JSON-over-HTTP client (stdlib only). Raises on non-2xx."""
|
|
1187
|
+
import urllib.error
|
|
1188
|
+
import urllib.request
|
|
1189
|
+
|
|
1190
|
+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
1191
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
1192
|
+
if data is not None:
|
|
1193
|
+
req.add_header("content-type", "application/json")
|
|
1194
|
+
try:
|
|
1195
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
1196
|
+
body = resp.read()
|
|
1197
|
+
except urllib.error.HTTPError as exc:
|
|
1198
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
1199
|
+
raise RuntimeError(f"{exc.code} {exc.reason}: {detail}") from exc
|
|
1200
|
+
except urllib.error.URLError as exc:
|
|
1201
|
+
raise RuntimeError(str(exc.reason)) from exc
|
|
1202
|
+
parsed: dict[str, Any] = json.loads(body or b"null")
|
|
1203
|
+
return parsed
|