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