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,1076 @@
|
|
|
1
|
+
"""FastAPI backend for the code-constraints web viewer.
|
|
2
|
+
|
|
3
|
+
Endpoints are intentionally thin orchestrators over `code_constraints.core` + parsers. All
|
|
4
|
+
expensive artifacts (parsed XMIs) live on disk under `.cdec_cache/<project_id>/` so the
|
|
5
|
+
UI can reload freely. Every diagram is a JSON graph the client lays out; the server
|
|
6
|
+
renders no images. No auth — local use only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import tempfile
|
|
16
|
+
import uuid
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
|
22
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
23
|
+
from fastapi.responses import FileResponse, Response
|
|
24
|
+
from fastapi.staticfiles import StaticFiles
|
|
25
|
+
from git import Commit, GitCommandError, InvalidGitRepositoryError, Repo
|
|
26
|
+
from git.exc import ODBError
|
|
27
|
+
from pydantic import BaseModel
|
|
28
|
+
|
|
29
|
+
from code_constraints.lint.config import REFERENCE_FILENAME, RULES_FILENAME
|
|
30
|
+
|
|
31
|
+
from code_constraints.core.diff import diff_projects
|
|
32
|
+
from code_constraints.core.editor_io import project_from_json, project_to_json
|
|
33
|
+
from code_constraints.core.model import SUPPORTED_LANGUAGES
|
|
34
|
+
from code_constraints.core.graph_model import (
|
|
35
|
+
build_activity_change_list,
|
|
36
|
+
build_activity_graph,
|
|
37
|
+
build_change_list,
|
|
38
|
+
build_class_graph,
|
|
39
|
+
build_package_graph,
|
|
40
|
+
build_sequence_change_list,
|
|
41
|
+
build_sequence_graph,
|
|
42
|
+
)
|
|
43
|
+
from code_constraints.core.xmi_reader import read_project
|
|
44
|
+
from code_constraints.core.xmi_writer import build_tree as build_xmi_tree
|
|
45
|
+
from code_constraints.core.xmi_writer import write_project
|
|
46
|
+
|
|
47
|
+
from lxml import etree
|
|
48
|
+
|
|
49
|
+
CACHE_ROOT = Path(".cdec_cache").resolve()
|
|
50
|
+
|
|
51
|
+
app = FastAPI(title="code-constraints", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
|
52
|
+
app.add_middleware(
|
|
53
|
+
CORSMiddleware,
|
|
54
|
+
allow_origins=["*"],
|
|
55
|
+
allow_methods=["*"],
|
|
56
|
+
allow_headers=["*"],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------- models ----------
|
|
61
|
+
|
|
62
|
+
class ProjectRegistration(BaseModel):
|
|
63
|
+
path: str
|
|
64
|
+
lang: str # python | csharp | typescript | svelte
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DiffRequest(BaseModel):
|
|
68
|
+
old_ref: str
|
|
69
|
+
new_ref: str
|
|
70
|
+
subpath: str = ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ProjectInfo(BaseModel):
|
|
74
|
+
id: str
|
|
75
|
+
path: str
|
|
76
|
+
lang: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class XmiInfo(BaseModel):
|
|
80
|
+
id: str
|
|
81
|
+
project_id: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ProposalInfo(BaseModel):
|
|
85
|
+
"""Latest architecture proposal pushed for a project.
|
|
86
|
+
|
|
87
|
+
`seq` increments on every push so an open browser tab can poll
|
|
88
|
+
`GET /api/projects/{id}/proposal` and hot-swap the diagram when it grows.
|
|
89
|
+
"""
|
|
90
|
+
id: str # xmi id of the annotated (proposal vs. baseline) diff
|
|
91
|
+
project_id: str
|
|
92
|
+
seq: int
|
|
93
|
+
focus: list[str] = []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DiagramListing(BaseModel):
|
|
97
|
+
classes: bool
|
|
98
|
+
packages: bool
|
|
99
|
+
activities: list[str]
|
|
100
|
+
sequences: list[str]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class GitInfo(BaseModel):
|
|
104
|
+
"""Git-tracking metadata about a project's path.
|
|
105
|
+
|
|
106
|
+
`is_git=False` (with everything else None) means the path is not inside a
|
|
107
|
+
git repo — the homepage hides the quick-diff card in that case.
|
|
108
|
+
|
|
109
|
+
`repo_root` is the absolute path of the git working tree root and
|
|
110
|
+
`subpath` is the project path expressed relative to it. If the project
|
|
111
|
+
path IS the repo root, `subpath` is the empty string. Used by the
|
|
112
|
+
quick-diff flow so the diff is scoped to just the registered project
|
|
113
|
+
rather than the whole repo.
|
|
114
|
+
"""
|
|
115
|
+
is_git: bool
|
|
116
|
+
branch: Optional[str] = None
|
|
117
|
+
head_sha: Optional[str] = None
|
|
118
|
+
head_short: Optional[str] = None
|
|
119
|
+
head_subject: Optional[str] = None
|
|
120
|
+
parent_sha: Optional[str] = None
|
|
121
|
+
parent_short: Optional[str] = None
|
|
122
|
+
parent_subject: Optional[str] = None
|
|
123
|
+
is_dirty: Optional[bool] = None
|
|
124
|
+
repo_root: Optional[str] = None
|
|
125
|
+
subpath: Optional[str] = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------- registry (in-memory; backed by per-project cache dirs on disk) ----------
|
|
129
|
+
|
|
130
|
+
class _Registry:
|
|
131
|
+
def __init__(self) -> None:
|
|
132
|
+
self._projects: dict[str, ProjectInfo] = {}
|
|
133
|
+
self._xmis: dict[str, XmiInfo] = {}
|
|
134
|
+
self._proposals: dict[str, ProposalInfo] = {}
|
|
135
|
+
|
|
136
|
+
def register(self, info: ProjectInfo) -> None:
|
|
137
|
+
self._projects[info.id] = info
|
|
138
|
+
|
|
139
|
+
def project(self, project_id: str) -> ProjectInfo:
|
|
140
|
+
if project_id not in self._projects:
|
|
141
|
+
raise HTTPException(status_code=404, detail=f"unknown project {project_id}")
|
|
142
|
+
return self._projects[project_id]
|
|
143
|
+
|
|
144
|
+
def register_xmi(self, info: XmiInfo) -> None:
|
|
145
|
+
self._xmis[info.id] = info
|
|
146
|
+
|
|
147
|
+
def xmi(self, xmi_id: str) -> XmiInfo:
|
|
148
|
+
if xmi_id not in self._xmis:
|
|
149
|
+
raise HTTPException(status_code=404, detail=f"unknown xmi {xmi_id}")
|
|
150
|
+
return self._xmis[xmi_id]
|
|
151
|
+
|
|
152
|
+
def record_proposal(
|
|
153
|
+
self, project_id: str, xmi_id: str, focus: list[str]
|
|
154
|
+
) -> ProposalInfo:
|
|
155
|
+
prev = self._proposals.get(project_id)
|
|
156
|
+
info = ProposalInfo(
|
|
157
|
+
id=xmi_id,
|
|
158
|
+
project_id=project_id,
|
|
159
|
+
seq=(prev.seq + 1) if prev else 1,
|
|
160
|
+
focus=focus,
|
|
161
|
+
)
|
|
162
|
+
self._proposals[project_id] = info
|
|
163
|
+
return info
|
|
164
|
+
|
|
165
|
+
def proposal(self, project_id: str) -> Optional[ProposalInfo]:
|
|
166
|
+
return self._proposals.get(project_id)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
_registry = _Registry()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _project_cache(project_id: str) -> Path:
|
|
173
|
+
p = CACHE_ROOT / project_id
|
|
174
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
return p
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _xmi_path(xmi_id: str) -> Path:
|
|
179
|
+
info = _registry.xmi(xmi_id)
|
|
180
|
+
return _project_cache(info.project_id) / f"{xmi_id}.xmi"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_SUPPORTED_LANGS = SUPPORTED_LANGUAGES
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse(lang: str, path: Path):
|
|
187
|
+
if lang == "python":
|
|
188
|
+
from code_constraints.python import parse_project
|
|
189
|
+
|
|
190
|
+
return parse_project(path)
|
|
191
|
+
if lang == "csharp":
|
|
192
|
+
from code_constraints.csharp import parse_project
|
|
193
|
+
|
|
194
|
+
return parse_project(path)
|
|
195
|
+
if lang == "typescript":
|
|
196
|
+
from code_constraints.typescript import parse_project
|
|
197
|
+
|
|
198
|
+
return parse_project(path)
|
|
199
|
+
if lang == "svelte":
|
|
200
|
+
from code_constraints.svelte import parse_project
|
|
201
|
+
|
|
202
|
+
return parse_project(path)
|
|
203
|
+
if lang == "odin":
|
|
204
|
+
from code_constraints.odin import parse_project
|
|
205
|
+
|
|
206
|
+
return parse_project(path)
|
|
207
|
+
if lang == "lua":
|
|
208
|
+
from code_constraints.lua import parse_project
|
|
209
|
+
|
|
210
|
+
return parse_project(path)
|
|
211
|
+
if lang == "julia":
|
|
212
|
+
from code_constraints.julia import parse_project
|
|
213
|
+
|
|
214
|
+
return parse_project(path)
|
|
215
|
+
raise HTTPException(status_code=400, detail=f"unsupported language: {lang}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------- endpoints ----------
|
|
219
|
+
|
|
220
|
+
@app.post("/api/projects", response_model=ProjectInfo)
|
|
221
|
+
def register_project(body: ProjectRegistration) -> ProjectInfo:
|
|
222
|
+
p = Path(body.path).expanduser().resolve()
|
|
223
|
+
if not p.is_dir():
|
|
224
|
+
raise HTTPException(status_code=400, detail=f"not a directory: {p}")
|
|
225
|
+
if body.lang not in _SUPPORTED_LANGS:
|
|
226
|
+
raise HTTPException(status_code=400, detail=f"unsupported language: {body.lang}")
|
|
227
|
+
project_id = hashlib.sha1(f"{p}|{body.lang}".encode("utf-8")).hexdigest()[:12]
|
|
228
|
+
info = ProjectInfo(id=project_id, path=str(p), lang=body.lang)
|
|
229
|
+
_registry.register(info)
|
|
230
|
+
return info
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.get("/api/projects", response_model=list[ProjectInfo])
|
|
234
|
+
def list_projects() -> list[ProjectInfo]:
|
|
235
|
+
return list(_registry._projects.values())
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class LayerDependencies(BaseModel):
|
|
239
|
+
"""The `layer-dependencies` allow-matrix declared in the project's
|
|
240
|
+
`.cdec/rules.yaml` (layer -> layers it may reference). Empty + source=None
|
|
241
|
+
when no such rule (or no `.cdec/`) is found."""
|
|
242
|
+
|
|
243
|
+
allow: dict[str, list[str]]
|
|
244
|
+
source: Optional[str] = None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class ViewListing(BaseModel):
|
|
248
|
+
name: str
|
|
249
|
+
filename: str
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
_SAFE_VIEW_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,57}\.json$")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _safe_view_filename(filename: str) -> bool:
|
|
256
|
+
"""Return True only for safe, single-component filenames (no path traversal)."""
|
|
257
|
+
return (
|
|
258
|
+
bool(_SAFE_VIEW_RE.match(filename))
|
|
259
|
+
and "/" not in filename
|
|
260
|
+
and "\\" not in filename
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _find_cdec_dir(root: Path) -> Path:
|
|
265
|
+
"""Walk from `root` up to the first .git boundary looking for a `.cdec/` directory.
|
|
266
|
+
If none is found, returns `root / '.cdec'` as the default (created on first write).
|
|
267
|
+
Raises 400 when `root` is not a real directory (e.g. synthetic diff projects).
|
|
268
|
+
"""
|
|
269
|
+
if not root.is_dir():
|
|
270
|
+
raise HTTPException(status_code=400, detail="project has no filesystem path")
|
|
271
|
+
current = root.resolve()
|
|
272
|
+
while True:
|
|
273
|
+
candidate = current / ".cdec"
|
|
274
|
+
if candidate.is_dir():
|
|
275
|
+
return candidate
|
|
276
|
+
if (current / ".git").exists():
|
|
277
|
+
break
|
|
278
|
+
if current.parent == current:
|
|
279
|
+
break
|
|
280
|
+
current = current.parent
|
|
281
|
+
return root.resolve() / ".cdec"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _find_rules_yaml(root: Path) -> Optional[Path]:
|
|
285
|
+
"""Locate `.cdec/rules.yaml` at `root` or walk up to an ancestor that has it
|
|
286
|
+
(stopping at a `.git` boundary or the filesystem root)."""
|
|
287
|
+
if not root.is_dir():
|
|
288
|
+
return None
|
|
289
|
+
current = root.resolve()
|
|
290
|
+
while True:
|
|
291
|
+
candidate = current / ".cdec" / RULES_FILENAME
|
|
292
|
+
if candidate.is_file():
|
|
293
|
+
return candidate
|
|
294
|
+
if (current / ".git").exists():
|
|
295
|
+
break
|
|
296
|
+
if current.parent == current:
|
|
297
|
+
break
|
|
298
|
+
current = current.parent
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@app.get("/api/projects/{project_id}/layers", response_model=LayerDependencies)
|
|
303
|
+
def layer_dependencies(project_id: str) -> LayerDependencies:
|
|
304
|
+
"""Expose the layer-dependency allow-matrix from the project's rules.yaml so
|
|
305
|
+
the class diagram can show it when a `@layer` badge is clicked."""
|
|
306
|
+
info = _registry.project(project_id)
|
|
307
|
+
rules_path = _find_rules_yaml(Path(info.path))
|
|
308
|
+
if rules_path is None:
|
|
309
|
+
return LayerDependencies(allow={}, source=None)
|
|
310
|
+
data = yaml.safe_load(rules_path.read_text(encoding="utf-8")) or {}
|
|
311
|
+
allow: dict[str, list[str]] = {}
|
|
312
|
+
for entry in data.get("rules") or []:
|
|
313
|
+
if isinstance(entry, dict) and entry.get("type") == "layer-dependencies":
|
|
314
|
+
for key, vals in (entry.get("allow") or {}).items():
|
|
315
|
+
allow[str(key)] = [str(v) for v in (vals or [])]
|
|
316
|
+
return LayerDependencies(allow=allow, source=str(rules_path))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@app.get("/api/projects/{project_id}/views", response_model=list[ViewListing])
|
|
320
|
+
def list_views(project_id: str) -> list[ViewListing]:
|
|
321
|
+
"""List view files saved to the project's .cdec/views/ directory."""
|
|
322
|
+
info = _registry.project(project_id)
|
|
323
|
+
cdec_dir = _find_cdec_dir(Path(info.path))
|
|
324
|
+
views_dir = cdec_dir / "views"
|
|
325
|
+
if not views_dir.is_dir():
|
|
326
|
+
return []
|
|
327
|
+
results: list[ViewListing] = []
|
|
328
|
+
for f in sorted(views_dir.glob("*.json")):
|
|
329
|
+
try:
|
|
330
|
+
data = json.loads(f.read_text(encoding="utf-8"))
|
|
331
|
+
name = data.get("name") or f.stem
|
|
332
|
+
except Exception:
|
|
333
|
+
name = f.stem
|
|
334
|
+
results.append(ViewListing(name=name, filename=f.name))
|
|
335
|
+
return results
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@app.get("/api/projects/{project_id}/views/{filename}")
|
|
339
|
+
def get_view(project_id: str, filename: str) -> dict:
|
|
340
|
+
"""Return the raw JSON of a named view file."""
|
|
341
|
+
if not _safe_view_filename(filename):
|
|
342
|
+
raise HTTPException(status_code=400, detail=f"invalid filename: {filename!r}")
|
|
343
|
+
info = _registry.project(project_id)
|
|
344
|
+
cdec_dir = _find_cdec_dir(Path(info.path))
|
|
345
|
+
path = cdec_dir / "views" / filename
|
|
346
|
+
if not path.is_file():
|
|
347
|
+
raise HTTPException(status_code=404, detail=f"view not found: {filename!r}")
|
|
348
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@app.put("/api/projects/{project_id}/views/{filename}", status_code=204)
|
|
352
|
+
async def save_view(project_id: str, filename: str, request: Request) -> Response:
|
|
353
|
+
"""Write a view JSON to the project's .cdec/views/ directory."""
|
|
354
|
+
if not _safe_view_filename(filename):
|
|
355
|
+
raise HTTPException(status_code=400, detail=f"invalid filename: {filename!r}")
|
|
356
|
+
try:
|
|
357
|
+
body = await request.json()
|
|
358
|
+
except Exception as exc:
|
|
359
|
+
raise HTTPException(status_code=400, detail=f"invalid JSON: {exc}") from exc
|
|
360
|
+
if not isinstance(body, dict):
|
|
361
|
+
raise HTTPException(status_code=400, detail="body must be a JSON object")
|
|
362
|
+
if body.get("schema") != "code-constraints/view@2":
|
|
363
|
+
raise HTTPException(
|
|
364
|
+
status_code=400,
|
|
365
|
+
detail=(
|
|
366
|
+
f"unrecognised schema {body.get('schema')!r}; "
|
|
367
|
+
"expected 'code-constraints/view@2'"
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
if not isinstance(body.get("visible"), list):
|
|
371
|
+
raise HTTPException(status_code=400, detail="'visible' must be a list")
|
|
372
|
+
info = _registry.project(project_id)
|
|
373
|
+
cdec_dir = _find_cdec_dir(Path(info.path))
|
|
374
|
+
views_dir = cdec_dir / "views"
|
|
375
|
+
views_dir.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
(views_dir / filename).write_text(
|
|
377
|
+
json.dumps(body, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
378
|
+
)
|
|
379
|
+
return Response(status_code=204)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@app.delete("/api/projects/{project_id}/views/{filename}", status_code=204)
|
|
383
|
+
def delete_view(project_id: str, filename: str) -> Response:
|
|
384
|
+
"""Delete a view file from the project's .cdec/views/ directory."""
|
|
385
|
+
if not _safe_view_filename(filename):
|
|
386
|
+
raise HTTPException(status_code=400, detail=f"invalid filename: {filename!r}")
|
|
387
|
+
info = _registry.project(project_id)
|
|
388
|
+
cdec_dir = _find_cdec_dir(Path(info.path))
|
|
389
|
+
path = cdec_dir / "views" / filename
|
|
390
|
+
if not path.is_file():
|
|
391
|
+
raise HTTPException(status_code=404, detail=f"view not found: {filename!r}")
|
|
392
|
+
path.unlink()
|
|
393
|
+
return Response(status_code=204)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class ReferenceResult(BaseModel):
|
|
397
|
+
path: str
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@app.put("/api/projects/{project_id}/reference", response_model=ReferenceResult)
|
|
401
|
+
async def set_reference(project_id: str, request: Request) -> ReferenceResult:
|
|
402
|
+
"""Write raw XMI bytes (the current/edited diagram) to the project's
|
|
403
|
+
`.cdec/reference.xmi`, after validating they parse as a Project. Used by the
|
|
404
|
+
'Set as reference' button to seed or update the architecture baseline."""
|
|
405
|
+
info = _registry.project(project_id)
|
|
406
|
+
body = await request.body()
|
|
407
|
+
if not body:
|
|
408
|
+
raise HTTPException(status_code=400, detail="empty request body")
|
|
409
|
+
# Validate the bytes parse as a real Project before overwriting the baseline.
|
|
410
|
+
with tempfile.NamedTemporaryFile(suffix=".xmi", delete=False) as tmp:
|
|
411
|
+
tmp.write(body)
|
|
412
|
+
tmp_path = Path(tmp.name)
|
|
413
|
+
try:
|
|
414
|
+
read_project(tmp_path)
|
|
415
|
+
except Exception as exc:
|
|
416
|
+
raise HTTPException(status_code=400, detail=f"not a valid XMI: {exc}") from exc
|
|
417
|
+
finally:
|
|
418
|
+
tmp_path.unlink(missing_ok=True)
|
|
419
|
+
cdec_dir = _find_cdec_dir(Path(info.path))
|
|
420
|
+
cdec_dir.mkdir(parents=True, exist_ok=True)
|
|
421
|
+
ref_path = cdec_dir / REFERENCE_FILENAME
|
|
422
|
+
ref_path.write_bytes(body)
|
|
423
|
+
return ReferenceResult(path=str(ref_path))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@app.post("/api/projects/{project_id}/proposal", response_model=ProposalInfo)
|
|
427
|
+
async def push_proposal(
|
|
428
|
+
project_id: str,
|
|
429
|
+
request: Request,
|
|
430
|
+
against: str = "source",
|
|
431
|
+
focus: str = "",
|
|
432
|
+
) -> ProposalInfo:
|
|
433
|
+
"""Push a proposed target architecture (editor-JSON model) for review.
|
|
434
|
+
|
|
435
|
+
Diffs the proposal (NEW side) against a baseline (OLD side) and registers
|
|
436
|
+
the annotated result, so the viewer shows green = "the code still needs to
|
|
437
|
+
grow this", red = "the proposal drops this" — the same orientation as
|
|
438
|
+
`cdec reference show`. `against` picks the baseline:
|
|
439
|
+
|
|
440
|
+
- `source` (default): the project's current parsed source tree
|
|
441
|
+
- `reference`: the project's `.cdec/reference.xmi`
|
|
442
|
+
- `none`: no diff; the proposal renders standalone
|
|
443
|
+
|
|
444
|
+
Every push bumps a sequence number; an open viewer polls
|
|
445
|
+
`GET /api/projects/{id}/proposal` and refreshes in place, which is what
|
|
446
|
+
makes the agent → human review loop fluent (no new tabs per iteration).
|
|
447
|
+
`focus` is an optional comma-separated list of qualified class names the
|
|
448
|
+
viewer should pre-filter to.
|
|
449
|
+
"""
|
|
450
|
+
info = _registry.project(project_id)
|
|
451
|
+
try:
|
|
452
|
+
data = await request.json()
|
|
453
|
+
except Exception as exc:
|
|
454
|
+
raise HTTPException(status_code=400, detail=f"invalid JSON: {exc}") from exc
|
|
455
|
+
if not isinstance(data, dict):
|
|
456
|
+
raise HTTPException(status_code=400, detail="payload must be a JSON object")
|
|
457
|
+
try:
|
|
458
|
+
proposal_proj = project_from_json(data)
|
|
459
|
+
except Exception as exc:
|
|
460
|
+
raise HTTPException(
|
|
461
|
+
status_code=400, detail=f"could not build Project: {exc}"
|
|
462
|
+
) from exc
|
|
463
|
+
|
|
464
|
+
if against == "source":
|
|
465
|
+
source_dir = Path(info.path)
|
|
466
|
+
if not source_dir.is_dir():
|
|
467
|
+
raise HTTPException(
|
|
468
|
+
status_code=400,
|
|
469
|
+
detail=f"project path is not a directory: {info.path} "
|
|
470
|
+
"(use against=none for synthetic projects)",
|
|
471
|
+
)
|
|
472
|
+
baseline = _parse(info.lang, source_dir)
|
|
473
|
+
elif against == "reference":
|
|
474
|
+
ref_path = _find_cdec_dir(Path(info.path)) / REFERENCE_FILENAME
|
|
475
|
+
if not ref_path.is_file():
|
|
476
|
+
raise HTTPException(status_code=400, detail=f"no reference XMI at {ref_path}")
|
|
477
|
+
baseline = read_project(ref_path)
|
|
478
|
+
elif against == "none":
|
|
479
|
+
baseline = None
|
|
480
|
+
else:
|
|
481
|
+
raise HTTPException(
|
|
482
|
+
status_code=400, detail=f"against must be source|reference|none, got {against!r}"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
if baseline is not None:
|
|
486
|
+
try:
|
|
487
|
+
annotated = diff_projects(baseline, proposal_proj)
|
|
488
|
+
except ValueError as exc:
|
|
489
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
490
|
+
else:
|
|
491
|
+
annotated = proposal_proj
|
|
492
|
+
|
|
493
|
+
xmi_id = uuid.uuid4().hex[:12]
|
|
494
|
+
write_project(annotated, _project_cache(project_id) / f"{xmi_id}.xmi")
|
|
495
|
+
_registry.register_xmi(XmiInfo(id=xmi_id, project_id=project_id))
|
|
496
|
+
focus_list = [f.strip() for f in focus.split(",") if f.strip()]
|
|
497
|
+
return _registry.record_proposal(project_id, xmi_id, focus_list)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@app.get("/api/projects/{project_id}/proposal", response_model=ProposalInfo)
|
|
501
|
+
def latest_proposal(project_id: str) -> ProposalInfo:
|
|
502
|
+
"""Latest proposal pushed for this project (404 if none yet). Viewers poll
|
|
503
|
+
this and hot-swap the diagram when `seq` changes."""
|
|
504
|
+
_registry.project(project_id)
|
|
505
|
+
info = _registry.proposal(project_id)
|
|
506
|
+
if info is None:
|
|
507
|
+
raise HTTPException(status_code=404, detail="no proposal pushed yet")
|
|
508
|
+
return info
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@app.get("/api/projects/{project_id}/reference-model")
|
|
512
|
+
def reference_model(project_id: str) -> dict:
|
|
513
|
+
"""The project's `.cdec/reference.xmi` as editor-JSON. Used by the editor's
|
|
514
|
+
"Compare vs reference" mode to load a baseline without a file upload."""
|
|
515
|
+
info = _registry.project(project_id)
|
|
516
|
+
ref_path = _find_cdec_dir(Path(info.path)) / REFERENCE_FILENAME
|
|
517
|
+
if not ref_path.is_file():
|
|
518
|
+
raise HTTPException(status_code=404, detail=f"no reference XMI at {ref_path}")
|
|
519
|
+
try:
|
|
520
|
+
proj = read_project(ref_path)
|
|
521
|
+
except Exception as exc:
|
|
522
|
+
raise HTTPException(
|
|
523
|
+
status_code=500, detail=f"could not parse reference XMI: {exc}"
|
|
524
|
+
) from exc
|
|
525
|
+
return project_to_json(proj)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@app.post("/api/projects/{project_id}/parse", response_model=XmiInfo)
|
|
529
|
+
def parse_project_endpoint(project_id: str) -> XmiInfo:
|
|
530
|
+
info = _registry.project(project_id)
|
|
531
|
+
proj = _parse(info.lang, Path(info.path))
|
|
532
|
+
xmi_id = uuid.uuid4().hex[:12]
|
|
533
|
+
write_project(proj, _project_cache(project_id) / f"{xmi_id}.xmi")
|
|
534
|
+
xmi_info = XmiInfo(id=xmi_id, project_id=project_id)
|
|
535
|
+
_registry.register_xmi(xmi_info)
|
|
536
|
+
return xmi_info
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _commit_subject(commit: Optional[Commit]) -> Optional[str]:
|
|
540
|
+
"""First line of a commit message, or None when the object is unavailable.
|
|
541
|
+
|
|
542
|
+
A shallow clone knows HEAD's parent *hash* but does not have its object —
|
|
543
|
+
`git clone --depth 1`, and every default `actions/checkout`, produce one.
|
|
544
|
+
Reading `.message` triggers a lazy load that raises there, so the whole
|
|
545
|
+
endpoint used to 500. The hash is still worth reporting, so only the
|
|
546
|
+
subject degrades to None.
|
|
547
|
+
"""
|
|
548
|
+
if commit is None:
|
|
549
|
+
return None
|
|
550
|
+
try:
|
|
551
|
+
message = commit.message
|
|
552
|
+
except (ValueError, ODBError):
|
|
553
|
+
return None
|
|
554
|
+
return message.splitlines()[0] if message else None
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@app.get("/api/projects/{project_id}/git-info", response_model=GitInfo)
|
|
558
|
+
def git_info(project_id: str) -> GitInfo:
|
|
559
|
+
"""Summary of the project path's git state.
|
|
560
|
+
|
|
561
|
+
Returns `is_git=False` if the path isn't inside a git repo — the homepage
|
|
562
|
+
quick-diff card uses this to decide whether to offer "diff vs previous
|
|
563
|
+
commit". Otherwise returns HEAD + parent metadata for the quick-diff and
|
|
564
|
+
a `is_dirty` flag so the UI can warn the user that uncommitted changes
|
|
565
|
+
won't appear in the diff.
|
|
566
|
+
"""
|
|
567
|
+
info = _registry.project(project_id)
|
|
568
|
+
try:
|
|
569
|
+
repo = Repo(info.path, search_parent_directories=True)
|
|
570
|
+
except InvalidGitRepositoryError:
|
|
571
|
+
return GitInfo(is_git=False)
|
|
572
|
+
|
|
573
|
+
branch: Optional[str] = None
|
|
574
|
+
try:
|
|
575
|
+
branch = repo.active_branch.name
|
|
576
|
+
except TypeError:
|
|
577
|
+
# Detached HEAD — not on a branch.
|
|
578
|
+
branch = None
|
|
579
|
+
|
|
580
|
+
head = repo.head.commit
|
|
581
|
+
parent = head.parents[0] if head.parents else None
|
|
582
|
+
repo_root = Path(repo.working_tree_dir).resolve() if repo.working_tree_dir else None
|
|
583
|
+
project_path = Path(info.path).resolve()
|
|
584
|
+
subpath = ""
|
|
585
|
+
if repo_root is not None:
|
|
586
|
+
try:
|
|
587
|
+
rel = project_path.relative_to(repo_root)
|
|
588
|
+
subpath = "" if str(rel) == "." else rel.as_posix()
|
|
589
|
+
except ValueError:
|
|
590
|
+
subpath = ""
|
|
591
|
+
return GitInfo(
|
|
592
|
+
is_git=True,
|
|
593
|
+
branch=branch,
|
|
594
|
+
head_sha=head.hexsha,
|
|
595
|
+
head_short=head.hexsha[:10],
|
|
596
|
+
head_subject=_commit_subject(head) or "",
|
|
597
|
+
parent_sha=parent.hexsha if parent else None,
|
|
598
|
+
parent_short=parent.hexsha[:10] if parent else None,
|
|
599
|
+
parent_subject=_commit_subject(parent),
|
|
600
|
+
is_dirty=repo.is_dirty(untracked_files=False),
|
|
601
|
+
repo_root=str(repo_root) if repo_root else None,
|
|
602
|
+
subpath=subpath,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@app.get("/api/projects/{project_id}/refs", response_model=list[str])
|
|
607
|
+
def list_refs(project_id: str) -> list[str]:
|
|
608
|
+
info = _registry.project(project_id)
|
|
609
|
+
try:
|
|
610
|
+
repo = Repo(info.path, search_parent_directories=True)
|
|
611
|
+
except InvalidGitRepositoryError as exc:
|
|
612
|
+
raise HTTPException(status_code=400, detail=f"not a git repo: {info.path}") from exc
|
|
613
|
+
refs: list[str] = []
|
|
614
|
+
refs.extend(b.name for b in repo.branches)
|
|
615
|
+
refs.extend(t.name for t in repo.tags)
|
|
616
|
+
# also the last 10 commit hashes for convenience
|
|
617
|
+
try:
|
|
618
|
+
refs.extend(c.hexsha[:10] for c in list(repo.iter_commits(max_count=10)))
|
|
619
|
+
except GitCommandError:
|
|
620
|
+
pass
|
|
621
|
+
# dedupe preserving order
|
|
622
|
+
seen: set[str] = set()
|
|
623
|
+
out: list[str] = []
|
|
624
|
+
for r in refs:
|
|
625
|
+
if r not in seen:
|
|
626
|
+
out.append(r)
|
|
627
|
+
seen.add(r)
|
|
628
|
+
return out
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@app.post("/api/projects/{project_id}/diff", response_model=XmiInfo)
|
|
632
|
+
def diff_endpoint(project_id: str, body: DiffRequest) -> XmiInfo:
|
|
633
|
+
"""Diff two git refs and write an annotated XMI.
|
|
634
|
+
|
|
635
|
+
Handles the common quick-diff case where the project sits in a subpath
|
|
636
|
+
that didn't exist on one side of the diff (e.g. a newly-added directory):
|
|
637
|
+
the missing side is treated as an empty Project of the same language,
|
|
638
|
+
so the result shows everything as ADDED rather than 500-ing.
|
|
639
|
+
"""
|
|
640
|
+
info = _registry.project(project_id)
|
|
641
|
+
repo = Repo(info.path, search_parent_directories=True)
|
|
642
|
+
with tempfile.TemporaryDirectory(prefix="cdec-diff-") as tmp:
|
|
643
|
+
old_dir = _checkout(repo, body.old_ref, Path(tmp) / "old")
|
|
644
|
+
new_dir = _checkout(repo, body.new_ref, Path(tmp) / "new")
|
|
645
|
+
old_t = old_dir / body.subpath if body.subpath else old_dir
|
|
646
|
+
new_t = new_dir / body.subpath if body.subpath else new_dir
|
|
647
|
+
old_proj = _parse_or_empty(info.lang, old_t)
|
|
648
|
+
new_proj = _parse_or_empty(info.lang, new_t)
|
|
649
|
+
annotated = diff_projects(old_proj, new_proj)
|
|
650
|
+
xmi_id = uuid.uuid4().hex[:12]
|
|
651
|
+
write_project(annotated, _project_cache(project_id) / f"{xmi_id}.xmi")
|
|
652
|
+
xmi_info = XmiInfo(id=xmi_id, project_id=project_id)
|
|
653
|
+
_registry.register_xmi(xmi_info)
|
|
654
|
+
return xmi_info
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@app.post("/api/projects/diff-vs-xmi", response_model=XmiInfo)
|
|
658
|
+
async def diff_source_vs_xmi(
|
|
659
|
+
reference_xmi: UploadFile = File(...),
|
|
660
|
+
path: str = Form(...),
|
|
661
|
+
lang: str = Form(...),
|
|
662
|
+
) -> XmiInfo:
|
|
663
|
+
"""Parse a source tree and diff it against an uploaded reference XMI.
|
|
664
|
+
|
|
665
|
+
Treats the uploaded XMI as the OLD side and the live source as the NEW
|
|
666
|
+
side. The reference XMI's `source_language` must match `lang`. Useful
|
|
667
|
+
when you've checkpointed an earlier model snapshot and want to see how
|
|
668
|
+
the current code has evolved against it.
|
|
669
|
+
"""
|
|
670
|
+
if lang not in _SUPPORTED_LANGS:
|
|
671
|
+
raise HTTPException(status_code=400, detail=f"unsupported language: {lang}")
|
|
672
|
+
source_dir = Path(path).expanduser().resolve()
|
|
673
|
+
if not source_dir.is_dir():
|
|
674
|
+
raise HTTPException(status_code=400, detail=f"not a directory: {source_dir}")
|
|
675
|
+
|
|
676
|
+
cache_root = CACHE_ROOT / "_uploaded"
|
|
677
|
+
cache_root.mkdir(parents=True, exist_ok=True)
|
|
678
|
+
ref_bytes = await reference_xmi.read()
|
|
679
|
+
if not ref_bytes:
|
|
680
|
+
raise HTTPException(status_code=400, detail="reference XMI must be non-empty")
|
|
681
|
+
with tempfile.NamedTemporaryFile(
|
|
682
|
+
suffix=".xmi", delete=False, dir=str(cache_root)
|
|
683
|
+
) as fh:
|
|
684
|
+
fh.write(ref_bytes)
|
|
685
|
+
ref_tmp = Path(fh.name)
|
|
686
|
+
try:
|
|
687
|
+
try:
|
|
688
|
+
old_proj = read_project(ref_tmp)
|
|
689
|
+
except Exception as exc:
|
|
690
|
+
raise HTTPException(
|
|
691
|
+
status_code=400, detail=f"could not parse reference XMI: {exc}"
|
|
692
|
+
) from exc
|
|
693
|
+
new_proj = _parse(lang, source_dir)
|
|
694
|
+
try:
|
|
695
|
+
annotated = diff_projects(old_proj, new_proj)
|
|
696
|
+
except ValueError as exc:
|
|
697
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
698
|
+
finally:
|
|
699
|
+
ref_tmp.unlink(missing_ok=True)
|
|
700
|
+
|
|
701
|
+
project_id = "u" + uuid.uuid4().hex[:11]
|
|
702
|
+
ref_label = reference_xmi.filename or "reference.xmi"
|
|
703
|
+
_registry.register(
|
|
704
|
+
ProjectInfo(
|
|
705
|
+
id=project_id,
|
|
706
|
+
path=f"uploaded: {ref_label} → {source_dir}",
|
|
707
|
+
lang=annotated.source_language,
|
|
708
|
+
)
|
|
709
|
+
)
|
|
710
|
+
xmi_id = uuid.uuid4().hex[:12]
|
|
711
|
+
write_project(annotated, _project_cache(project_id) / f"{xmi_id}.xmi")
|
|
712
|
+
xmi_info = XmiInfo(id=xmi_id, project_id=project_id)
|
|
713
|
+
_registry.register_xmi(xmi_info)
|
|
714
|
+
return xmi_info
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@app.post("/api/xmi/diff", response_model=XmiInfo)
|
|
718
|
+
async def diff_xmi_files(
|
|
719
|
+
old_xmi: UploadFile = File(...),
|
|
720
|
+
new_xmi: UploadFile = File(...),
|
|
721
|
+
) -> XmiInfo:
|
|
722
|
+
"""Diff two uploaded XMI files and write an annotated result.
|
|
723
|
+
|
|
724
|
+
Useful when you have two XMIs produced separately (e.g. by `cdec parse`
|
|
725
|
+
runs in CI) and don't have a single git repo to diff against. Both XMIs
|
|
726
|
+
must declare the same `source_language`.
|
|
727
|
+
|
|
728
|
+
The result is registered under a synthetic project so the existing
|
|
729
|
+
diagram viewer endpoints (`/api/xmi/{id}/model`, `/changes`, …) work
|
|
730
|
+
transparently. The synthetic project has a placeholder path and is NOT
|
|
731
|
+
re-parseable (no source tree behind it).
|
|
732
|
+
"""
|
|
733
|
+
cache_root = CACHE_ROOT / "_uploaded"
|
|
734
|
+
cache_root.mkdir(parents=True, exist_ok=True)
|
|
735
|
+
# Write the uploads to temp files so xmi_reader can ingest them.
|
|
736
|
+
old_bytes = await old_xmi.read()
|
|
737
|
+
new_bytes = await new_xmi.read()
|
|
738
|
+
if not old_bytes or not new_bytes:
|
|
739
|
+
raise HTTPException(status_code=400, detail="both XMI files must be non-empty")
|
|
740
|
+
with tempfile.NamedTemporaryFile(
|
|
741
|
+
suffix=".xmi", delete=False, dir=str(cache_root)
|
|
742
|
+
) as ofh:
|
|
743
|
+
ofh.write(old_bytes)
|
|
744
|
+
old_tmp = Path(ofh.name)
|
|
745
|
+
with tempfile.NamedTemporaryFile(
|
|
746
|
+
suffix=".xmi", delete=False, dir=str(cache_root)
|
|
747
|
+
) as nfh:
|
|
748
|
+
nfh.write(new_bytes)
|
|
749
|
+
new_tmp = Path(nfh.name)
|
|
750
|
+
try:
|
|
751
|
+
try:
|
|
752
|
+
old_proj = read_project(old_tmp)
|
|
753
|
+
new_proj = read_project(new_tmp)
|
|
754
|
+
except Exception as exc: # malformed XML, missing root, etc.
|
|
755
|
+
raise HTTPException(
|
|
756
|
+
status_code=400, detail=f"could not parse uploaded XMI: {exc}"
|
|
757
|
+
) from exc
|
|
758
|
+
try:
|
|
759
|
+
annotated = diff_projects(old_proj, new_proj)
|
|
760
|
+
except ValueError as exc:
|
|
761
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
762
|
+
finally:
|
|
763
|
+
old_tmp.unlink(missing_ok=True)
|
|
764
|
+
new_tmp.unlink(missing_ok=True)
|
|
765
|
+
|
|
766
|
+
# Register a synthetic project so the registry can later resolve the
|
|
767
|
+
# XMI's project_id. We use a fresh id per upload so concurrent diffs
|
|
768
|
+
# don't trample each other's state.
|
|
769
|
+
project_id = "u" + uuid.uuid4().hex[:11]
|
|
770
|
+
label_old = old_xmi.filename or "old.xmi"
|
|
771
|
+
label_new = new_xmi.filename or "new.xmi"
|
|
772
|
+
_registry.register(
|
|
773
|
+
ProjectInfo(
|
|
774
|
+
id=project_id,
|
|
775
|
+
path=f"uploaded: {label_old} → {label_new}",
|
|
776
|
+
lang=annotated.source_language,
|
|
777
|
+
)
|
|
778
|
+
)
|
|
779
|
+
xmi_id = uuid.uuid4().hex[:12]
|
|
780
|
+
write_project(annotated, _project_cache(project_id) / f"{xmi_id}.xmi")
|
|
781
|
+
xmi_info = XmiInfo(id=xmi_id, project_id=project_id)
|
|
782
|
+
_registry.register_xmi(xmi_info)
|
|
783
|
+
return xmi_info
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _parse_or_empty(lang: str, path: Path):
|
|
787
|
+
"""Parse `path` if it exists; otherwise return an empty Project.
|
|
788
|
+
|
|
789
|
+
Used for git diffs where one side may not contain the requested subpath
|
|
790
|
+
(e.g. the directory was just added at HEAD and didn't exist at HEAD~1).
|
|
791
|
+
"""
|
|
792
|
+
if not path.exists():
|
|
793
|
+
from code_constraints.core.model import Project
|
|
794
|
+
|
|
795
|
+
return Project(source_language=lang) # type: ignore[arg-type]
|
|
796
|
+
return _parse(lang, path)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
@app.get("/api/xmi/{xmi_id}/diagrams", response_model=DiagramListing)
|
|
800
|
+
def list_diagrams(xmi_id: str) -> DiagramListing:
|
|
801
|
+
proj = read_project(_xmi_path(xmi_id))
|
|
802
|
+
return DiagramListing(
|
|
803
|
+
classes=bool(list(proj.iter_classes())),
|
|
804
|
+
packages=bool(proj.packages),
|
|
805
|
+
activities=[a.name for a in proj.activities],
|
|
806
|
+
sequences=[s.name for s in proj.sequences],
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
@app.get("/api/xmi/{xmi_id}/model")
|
|
811
|
+
def xmi_model(xmi_id: str, diagram: str = "class", name: Optional[str] = None) -> dict:
|
|
812
|
+
"""JSON graph payload (nodes + edges) for an interactive canvas.
|
|
813
|
+
|
|
814
|
+
Supported diagrams: class, package, activity, sequence.
|
|
815
|
+
"""
|
|
816
|
+
proj = read_project(_xmi_path(xmi_id))
|
|
817
|
+
if diagram == "class":
|
|
818
|
+
return build_class_graph(proj)
|
|
819
|
+
if diagram == "package":
|
|
820
|
+
return build_package_graph(proj)
|
|
821
|
+
if diagram == "activity":
|
|
822
|
+
if not name:
|
|
823
|
+
raise HTTPException(status_code=400, detail="activity diagram requires ?name=")
|
|
824
|
+
try:
|
|
825
|
+
return build_activity_graph(proj, name)
|
|
826
|
+
except KeyError as exc:
|
|
827
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
828
|
+
if diagram == "sequence":
|
|
829
|
+
if not name:
|
|
830
|
+
raise HTTPException(status_code=400, detail="sequence diagram requires ?name=")
|
|
831
|
+
try:
|
|
832
|
+
return build_sequence_graph(proj, name)
|
|
833
|
+
except KeyError as exc:
|
|
834
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
835
|
+
raise HTTPException(
|
|
836
|
+
status_code=400,
|
|
837
|
+
detail=f"unknown diagram type: {diagram}",
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@app.get("/api/xmi/{xmi_id}/changes")
|
|
842
|
+
def xmi_changes(xmi_id: str, kind: str = "class") -> list[dict]:
|
|
843
|
+
"""Ordered change list for the diff walkthrough.
|
|
844
|
+
|
|
845
|
+
`kind=class` (default) → one entry per affected class.
|
|
846
|
+
`kind=activity` → one entry per affected activity (added/removed/changed),
|
|
847
|
+
with member bullets for added/removed nodes and edges.
|
|
848
|
+
`kind=sequence` → one entry per affected sequence, with bullets for
|
|
849
|
+
added/removed lifelines and messages.
|
|
850
|
+
Returns an empty list for parse-only XMIs.
|
|
851
|
+
"""
|
|
852
|
+
proj = read_project(_xmi_path(xmi_id))
|
|
853
|
+
if kind == "class":
|
|
854
|
+
return build_change_list(proj)
|
|
855
|
+
if kind == "activity":
|
|
856
|
+
return build_activity_change_list(proj)
|
|
857
|
+
if kind == "sequence":
|
|
858
|
+
return build_sequence_change_list(proj)
|
|
859
|
+
raise HTTPException(status_code=400, detail=f"unknown changes kind: {kind}")
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
@app.get("/api/xmi/{xmi_id}/source")
|
|
863
|
+
def xmi_source(xmi_id: str) -> FileResponse:
|
|
864
|
+
return FileResponse(
|
|
865
|
+
_xmi_path(xmi_id), media_type="application/xml", filename=f"{xmi_id}.xmi"
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
# ---------- editor bridge: pure XMI <-> JSON converters, no on-disk state ----------
|
|
870
|
+
|
|
871
|
+
@app.post("/api/edit/from-xmi")
|
|
872
|
+
async def edit_from_xmi(file: UploadFile = File(...)) -> dict:
|
|
873
|
+
"""Parse an uploaded XMI file and return its Project as JSON.
|
|
874
|
+
|
|
875
|
+
Stateless: the file isn't stored, no registry entry. The editor uses this
|
|
876
|
+
both to open a downloaded .xmi and to bootstrap "Edit this parsed diagram"
|
|
877
|
+
by re-uploading the XMI it just fetched from /api/xmi/{id}/source.
|
|
878
|
+
"""
|
|
879
|
+
body = await file.read()
|
|
880
|
+
if not body:
|
|
881
|
+
raise HTTPException(status_code=400, detail="XMI file must be non-empty")
|
|
882
|
+
cache_root = CACHE_ROOT / "_uploaded"
|
|
883
|
+
cache_root.mkdir(parents=True, exist_ok=True)
|
|
884
|
+
with tempfile.NamedTemporaryFile(
|
|
885
|
+
suffix=".xmi", delete=False, dir=str(cache_root)
|
|
886
|
+
) as fh:
|
|
887
|
+
fh.write(body)
|
|
888
|
+
tmp = Path(fh.name)
|
|
889
|
+
try:
|
|
890
|
+
try:
|
|
891
|
+
proj = read_project(tmp)
|
|
892
|
+
except Exception as exc:
|
|
893
|
+
raise HTTPException(
|
|
894
|
+
status_code=400, detail=f"could not parse XMI: {exc}"
|
|
895
|
+
) from exc
|
|
896
|
+
return project_to_json(proj)
|
|
897
|
+
finally:
|
|
898
|
+
tmp.unlink(missing_ok=True)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
@app.post("/api/edit/model")
|
|
902
|
+
async def edit_model(request: Request, diagram: str = "class") -> dict:
|
|
903
|
+
"""Build the SvelteFlow graph for an in-progress editor draft.
|
|
904
|
+
|
|
905
|
+
Takes the same JSON Project payload as `/api/edit/to-xmi`, runs it through
|
|
906
|
+
`project_from_json`, then returns the result of `build_class_graph` (or
|
|
907
|
+
package/activity/sequence depending on `diagram`). This is the same code
|
|
908
|
+
path used by `/api/xmi/{id}/model` so view mode and edit mode produce
|
|
909
|
+
identical graphs for equivalent models — no parallel TS implementation
|
|
910
|
+
can drift from the canonical Python one.
|
|
911
|
+
"""
|
|
912
|
+
try:
|
|
913
|
+
data = await request.json()
|
|
914
|
+
except Exception as exc:
|
|
915
|
+
raise HTTPException(status_code=400, detail=f"invalid JSON: {exc}") from exc
|
|
916
|
+
if not isinstance(data, dict):
|
|
917
|
+
raise HTTPException(status_code=400, detail="payload must be a JSON object")
|
|
918
|
+
try:
|
|
919
|
+
proj = project_from_json(data)
|
|
920
|
+
except Exception as exc:
|
|
921
|
+
raise HTTPException(
|
|
922
|
+
status_code=400, detail=f"could not build Project: {exc}"
|
|
923
|
+
) from exc
|
|
924
|
+
if diagram == "class":
|
|
925
|
+
return build_class_graph(proj)
|
|
926
|
+
if diagram == "package":
|
|
927
|
+
return build_package_graph(proj)
|
|
928
|
+
raise HTTPException(
|
|
929
|
+
status_code=400,
|
|
930
|
+
detail=f"unsupported editor diagram type: {diagram}",
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
@app.post("/api/edit/diff-model")
|
|
935
|
+
async def edit_diff_model(request: Request, diagram: str = "class") -> dict:
|
|
936
|
+
"""Build the SvelteFlow graph for an editor draft diffed against a baseline.
|
|
937
|
+
|
|
938
|
+
Body: `{"old": <ProjectJSON>, "new": <ProjectJSON>}`. The result is the
|
|
939
|
+
same graph shape as `/api/edit/model` but with diff statuses annotated, so
|
|
940
|
+
the editor can show live added/removed/changed styling while the user (or
|
|
941
|
+
an agent) reshapes the draft — simultaneous edit + diff preview.
|
|
942
|
+
"""
|
|
943
|
+
try:
|
|
944
|
+
data = await request.json()
|
|
945
|
+
except Exception as exc:
|
|
946
|
+
raise HTTPException(status_code=400, detail=f"invalid JSON: {exc}") from exc
|
|
947
|
+
if not isinstance(data, dict) or "old" not in data or "new" not in data:
|
|
948
|
+
raise HTTPException(
|
|
949
|
+
status_code=400, detail='payload must be {"old": {...}, "new": {...}}'
|
|
950
|
+
)
|
|
951
|
+
try:
|
|
952
|
+
old_proj = project_from_json(data["old"])
|
|
953
|
+
new_proj = project_from_json(data["new"])
|
|
954
|
+
except Exception as exc:
|
|
955
|
+
raise HTTPException(
|
|
956
|
+
status_code=400, detail=f"could not build Project: {exc}"
|
|
957
|
+
) from exc
|
|
958
|
+
try:
|
|
959
|
+
annotated = diff_projects(old_proj, new_proj)
|
|
960
|
+
except ValueError as exc:
|
|
961
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
962
|
+
if diagram == "class":
|
|
963
|
+
return build_class_graph(annotated)
|
|
964
|
+
if diagram == "package":
|
|
965
|
+
return build_package_graph(annotated)
|
|
966
|
+
raise HTTPException(
|
|
967
|
+
status_code=400,
|
|
968
|
+
detail=f"unsupported editor diagram type: {diagram}",
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
@app.post("/api/edit/to-xmi")
|
|
973
|
+
async def edit_to_xmi(request: Request) -> Response:
|
|
974
|
+
"""Serialise an editor-model JSON payload back to XMI 2.1 text.
|
|
975
|
+
|
|
976
|
+
Returns the XMI as an attachment so the browser triggers a download.
|
|
977
|
+
"""
|
|
978
|
+
try:
|
|
979
|
+
data = await request.json()
|
|
980
|
+
except Exception as exc:
|
|
981
|
+
raise HTTPException(status_code=400, detail=f"invalid JSON: {exc}") from exc
|
|
982
|
+
if not isinstance(data, dict):
|
|
983
|
+
raise HTTPException(status_code=400, detail="payload must be a JSON object")
|
|
984
|
+
try:
|
|
985
|
+
proj = project_from_json(data)
|
|
986
|
+
except Exception as exc:
|
|
987
|
+
raise HTTPException(
|
|
988
|
+
status_code=400, detail=f"could not build Project: {exc}"
|
|
989
|
+
) from exc
|
|
990
|
+
tree = build_xmi_tree(proj)
|
|
991
|
+
xml_bytes = etree.tostring(
|
|
992
|
+
tree, pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
|
993
|
+
)
|
|
994
|
+
filename = data.get("download_name") or "diagram.xmi"
|
|
995
|
+
if not isinstance(filename, str) or not filename.endswith(".xmi"):
|
|
996
|
+
filename = "diagram.xmi"
|
|
997
|
+
return Response(
|
|
998
|
+
content=xml_bytes,
|
|
999
|
+
media_type="application/xml",
|
|
1000
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
# ---------- helpers ----------
|
|
1005
|
+
|
|
1006
|
+
def _checkout(repo: Repo, ref: str, dest: Path) -> Path:
|
|
1007
|
+
"""Extract the tree at `ref` into `dest`, without touching the working tree.
|
|
1008
|
+
|
|
1009
|
+
A shallow clone resolves refs whose objects it does not actually have, so
|
|
1010
|
+
both the lookup and the archive can fail on history that was never
|
|
1011
|
+
fetched. That is the caller asking for something absent, not a server
|
|
1012
|
+
fault, so it answers 400 with the command that fixes it.
|
|
1013
|
+
"""
|
|
1014
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
1015
|
+
archive = dest.with_suffix(".tar")
|
|
1016
|
+
try:
|
|
1017
|
+
commit = repo.commit(ref)
|
|
1018
|
+
with archive.open("wb") as fh:
|
|
1019
|
+
repo.archive(fh, treeish=commit.hexsha, format="tar")
|
|
1020
|
+
except (GitCommandError, ODBError, ValueError) as exc:
|
|
1021
|
+
raise HTTPException(
|
|
1022
|
+
status_code=400,
|
|
1023
|
+
detail=(
|
|
1024
|
+
f"cannot read git ref {ref!r}: {exc}. If this is a shallow clone, "
|
|
1025
|
+
f"the older commits were never fetched — run `git fetch --unshallow`."
|
|
1026
|
+
),
|
|
1027
|
+
) from exc
|
|
1028
|
+
shutil.unpack_archive(str(archive), str(dest), format="tar")
|
|
1029
|
+
archive.unlink()
|
|
1030
|
+
return dest
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
# ---------- static SPA mount ----------
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
class _NoCacheStaticFiles(StaticFiles):
|
|
1037
|
+
"""Serve the SPA bundle with caching disabled.
|
|
1038
|
+
|
|
1039
|
+
The default ``StaticFiles`` lets the browser cache ``index.html`` (via
|
|
1040
|
+
ETag/Last-Modified) and reuse it without revalidating. After a rebuild the
|
|
1041
|
+
hashed asset filenames change, but a stale cached ``index.html`` keeps
|
|
1042
|
+
pointing at the *old* hashes — the classic "my latest change isn't there"
|
|
1043
|
+
symptom. This is a local dev/inspection tool, so the cost of never caching
|
|
1044
|
+
is negligible; we force a fresh fetch every time.
|
|
1045
|
+
"""
|
|
1046
|
+
|
|
1047
|
+
def is_not_modified(self, response_headers, request_headers) -> bool: # type: ignore[override]
|
|
1048
|
+
# Never answer with a 304 — always send the current bytes.
|
|
1049
|
+
return False
|
|
1050
|
+
|
|
1051
|
+
async def get_response(self, path, scope): # type: ignore[override]
|
|
1052
|
+
response = await super().get_response(path, scope)
|
|
1053
|
+
response.headers["Cache-Control"] = "no-store, max-age=0"
|
|
1054
|
+
return response
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
# A source checkout (editable install, `make serve`) has the built SPA at
|
|
1058
|
+
# ``frontend/dist``; an installed wheel carries it as package data at
|
|
1059
|
+
# ``web/_static``. The checkout wins, so a stale embedded copy never shadows a
|
|
1060
|
+
# fresh `npm run build`.
|
|
1061
|
+
_here = Path(__file__).resolve()
|
|
1062
|
+
_frontend_dist = next(
|
|
1063
|
+
(d for d in (_here.parents[3] / "frontend" / "dist", _here.parent / "_static") if d.exists()),
|
|
1064
|
+
None,
|
|
1065
|
+
)
|
|
1066
|
+
if _frontend_dist is not None:
|
|
1067
|
+
app.mount("/", _NoCacheStaticFiles(directory=str(_frontend_dist), html=True), name="spa")
|
|
1068
|
+
else:
|
|
1069
|
+
|
|
1070
|
+
@app.get("/")
|
|
1071
|
+
def _placeholder() -> dict[str, str]:
|
|
1072
|
+
return {
|
|
1073
|
+
"status": "ok",
|
|
1074
|
+
"message": "Frontend not built. Run `npm install && npm run build` in /frontend.",
|
|
1075
|
+
"api_docs": "/api/docs",
|
|
1076
|
+
}
|