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