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,1555 @@
1
+ """`cdec` command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Optional, cast
8
+ from urllib.parse import quote
9
+
10
+ import typer
11
+ from git import Repo
12
+
13
+ from code_constraints.cli.detect import detect_language
14
+
15
+ from code_constraints.core.diff import diff_projects
16
+ from code_constraints.core.model import SUPPORTED_LANGUAGES, SourceLanguage
17
+ from code_constraints.core.model_io import UnsupportedModelFormat, load_model, save_model
18
+ from code_constraints.core.xmi_writer import write_project
19
+ from code_constraints.lint.baseline import load_baseline, write_baseline
20
+ from code_constraints.lint.config import (
21
+ REFERENCE_FILENAME,
22
+ ConfigError,
23
+ load_project_config,
24
+ load_rules,
25
+ )
26
+ from code_constraints.lint.engine import run_checks
27
+ from code_constraints.lint.rules.base import Severity
28
+
29
+ if TYPE_CHECKING:
30
+ # Annotation-only: the review machinery and the engine internals are
31
+ # imported lazily inside the commands that use them, so `cdec parse`
32
+ # doesn't pay for any of it.
33
+ from collections.abc import Callable
34
+
35
+ from code_constraints.lint.config import LoadedRules
36
+ from code_constraints.lint.engine import SourceContext
37
+ from code_constraints.lint.report import Report
38
+ from code_constraints.lint.rules.base import RuleContext
39
+ from code_constraints.waivers import ApplyResult, Collected, Issue, WaiverStore
40
+
41
+ _LANG_HELP = "python | csharp | typescript | svelte | odin | lua | julia"
42
+
43
+ app = typer.Typer(
44
+ help=(
45
+ "code-constraints (cdec) — enforce architectural and implementation "
46
+ "constraints on a codebase. "
47
+ "Model it with parse / convert / diff. Gate it with `cdec check`, which "
48
+ "runs every rule in .cdec/rules.yaml — architectural rules, source-tag "
49
+ "conformance, implementation locks and the reference-architecture gate "
50
+ "alike. Keep it moving with `cdec exceptions`, which records the "
51
+ "violations you accept and why."
52
+ )
53
+ )
54
+
55
+
56
+ @app.callback(invoke_without_command=True)
57
+ def _default(ctx: typer.Context) -> None:
58
+ """Launch the interactive session when `cdec` is run with no subcommand."""
59
+ if ctx.invoked_subcommand is not None:
60
+ return
61
+ from code_constraints.cli.interactive import run_interactive
62
+
63
+ run_interactive(Path.cwd())
64
+ raise typer.Exit()
65
+
66
+
67
+ @app.command()
68
+ def parse(
69
+ path: Path = typer.Argument(..., exists=True, file_okay=False, dir_okay=True),
70
+ lang: str = typer.Option(..., "--lang", help="python | csharp | typescript | svelte | odin | lua | julia"),
71
+ out: Path = typer.Option(..., "--out", help="Destination model file (.xmi or .json)"),
72
+ ) -> None:
73
+ """Parse a source tree and write a model file (XMI 2.1 or editor JSON)."""
74
+ project = _parse_project(path, lang)
75
+ _save_model_cli(project, out)
76
+ typer.echo(f"wrote {out}")
77
+
78
+
79
+ @app.command()
80
+ def convert(
81
+ src: Path = typer.Argument(..., exists=True, dir_okay=False, help="Model file to read (.xmi or .json)"),
82
+ dest: Path = typer.Argument(..., help="Destination model file (.xmi or .json)"),
83
+ ) -> None:
84
+ """Convert a model file between XMI 2.1 and editor JSON.
85
+
86
+ The JSON shape is the same one the web editor and the proposal endpoint
87
+ use, so `cdec convert model.xmi model.json` gives you a hand-editable /
88
+ agent-editable version of any parsed architecture — and converting back
89
+ produces standard XMI again.
90
+ """
91
+ project = _load_model_cli(src)
92
+ _save_model_cli(project, dest)
93
+ typer.echo(f"wrote {dest}")
94
+
95
+
96
+ @app.command()
97
+ def diff(
98
+ old_ref: str = typer.Argument(...),
99
+ new_ref: str = typer.Argument(...),
100
+ lang: str = typer.Option(..., "--lang"),
101
+ out: Path = typer.Option(..., "--out"),
102
+ repo: Path = typer.Option(Path("."), "--repo"),
103
+ subpath: str = typer.Option(
104
+ "", "--subpath", help="If set, only parse this directory of each revision."
105
+ ),
106
+ ) -> None:
107
+ """Diff two git revisions and emit an annotated XMI."""
108
+ repo_path = repo.resolve()
109
+ git_repo = Repo(str(repo_path))
110
+ with tempfile.TemporaryDirectory(prefix="cdec-diff-") as tmp:
111
+ old_dir = _checkout_revision(git_repo, old_ref, Path(tmp) / "old")
112
+ new_dir = _checkout_revision(git_repo, new_ref, Path(tmp) / "new")
113
+ target_old = old_dir / subpath if subpath else old_dir
114
+ target_new = new_dir / subpath if subpath else new_dir
115
+ old_proj = _parse_project(target_old, lang)
116
+ new_proj = _parse_project(target_new, lang)
117
+ annotated = diff_projects(old_proj, new_proj)
118
+ _save_model_cli(annotated, out)
119
+ typer.echo(f"wrote {out}")
120
+
121
+
122
+ @app.command("diff-vs-xmi")
123
+ def diff_vs_xmi(
124
+ reference_xmi: Path = typer.Argument(
125
+ ..., exists=True, dir_okay=False,
126
+ help="Reference XMI (the OLDER side of the diff).",
127
+ ),
128
+ source_path: Path = typer.Argument(
129
+ ..., exists=True, file_okay=False, dir_okay=True,
130
+ help="Source tree to parse (the NEWER side of the diff).",
131
+ ),
132
+ lang: str = typer.Option(..., "--lang", help="python | csharp | typescript | svelte | odin | lua | julia"),
133
+ out: Path = typer.Option(..., "--out", help="Destination annotated .xmi"),
134
+ ) -> None:
135
+ """Parse a source tree and diff it against a reference XMI snapshot.
136
+
137
+ Useful when you've checkpointed an earlier version of the model as an XMI
138
+ file and want to see what the current source code looks like relative to
139
+ it. The reference XMI is treated as the OLD side; the freshly-parsed
140
+ source is the NEW. Both must share the same `source_language`.
141
+ """
142
+ old_proj = _load_model_cli(reference_xmi)
143
+ new_proj = _parse_project(source_path, lang)
144
+ try:
145
+ annotated = diff_projects(old_proj, new_proj)
146
+ except ValueError as exc:
147
+ typer.echo(str(exc), err=True)
148
+ raise typer.Exit(code=1) from exc
149
+ _save_model_cli(annotated, out)
150
+ typer.echo(f"wrote {out}")
151
+
152
+
153
+ @app.command("diff-xmi")
154
+ def diff_xmi(
155
+ old_xmi: Path = typer.Argument(..., exists=True, dir_okay=False, help="Older XMI"),
156
+ new_xmi: Path = typer.Argument(..., exists=True, dir_okay=False, help="Newer XMI"),
157
+ out: Path = typer.Option(..., "--out", help="Destination annotated .xmi"),
158
+ ) -> None:
159
+ """Diff two existing XMI files and emit an annotated XMI.
160
+
161
+ Useful when you've already parsed two snapshots independently (e.g. CI
162
+ artefacts from different branches) and just want the diff without re-
163
+ parsing source. Both XMIs must declare the same `source_language`.
164
+ """
165
+ old_proj = _load_model_cli(old_xmi)
166
+ new_proj = _load_model_cli(new_xmi)
167
+ try:
168
+ annotated = diff_projects(old_proj, new_proj)
169
+ except ValueError as exc:
170
+ typer.echo(str(exc), err=True)
171
+ raise typer.Exit(code=1) from exc
172
+ _save_model_cli(annotated, out)
173
+ typer.echo(f"wrote {out}")
174
+
175
+
176
+ @app.command()
177
+ def init(
178
+ config_dir: Path = typer.Option(
179
+ Path(".cdec"), "--config", help="Directory to create (default: .cdec at cwd)."
180
+ ),
181
+ lang: str = typer.Option("python", "--lang", help="python | csharp | typescript | svelte | odin | lua | julia"),
182
+ source: Path = typer.Option(
183
+ Path("."), "--source", help="Source tree parsed by `cdec check`."
184
+ ),
185
+ force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
186
+ migrate: bool = typer.Option(
187
+ False, "--migrate",
188
+ help="Fold an existing config.yaml / baseline.yaml / locks.yaml into "
189
+ "rules.yaml and delete them, instead of scaffolding.",
190
+ ),
191
+ ) -> None:
192
+ """Scaffold `.cdec/rules.yaml` and a reference snapshot.
193
+
194
+ One file holds the project settings, the rules, the exceptions granted and
195
+ the digests of frozen implementations, so there is one thing to commit and
196
+ one diff to review. `--migrate` converts a project that still has the old
197
+ per-concern files.
198
+ """
199
+ from code_constraints.cli.scaffold import (
200
+ SUPPORTED_LANGS,
201
+ ScaffoldError,
202
+ init_cdec_config,
203
+ migrate_cdec_config,
204
+ )
205
+
206
+ if migrate:
207
+ try:
208
+ written, removed = migrate_cdec_config(config_dir)
209
+ except ScaffoldError as exc:
210
+ typer.echo(str(exc), err=True)
211
+ raise typer.Exit(code=1) from exc
212
+ if not written and not removed:
213
+ typer.echo(f"{config_dir}: nothing to migrate — already on rules.yaml.")
214
+ return
215
+ for path in written:
216
+ typer.echo(f"wrote {path}")
217
+ for path in removed:
218
+ typer.echo(f"removed {path} (its content now lives in rules.yaml)")
219
+ typer.echo("Review the diff, then commit .cdec/rules.yaml.")
220
+ return
221
+
222
+ if lang not in SUPPORTED_LANGS:
223
+ raise typer.BadParameter(f"unsupported language: {lang}")
224
+ if not source.exists():
225
+ typer.echo(f"source {source} does not exist; skipping reference snapshot.", err=True)
226
+ try:
227
+ written = init_cdec_config(config_dir, lang, source, force=force)
228
+ except ScaffoldError as exc:
229
+ typer.echo(str(exc), err=True)
230
+ raise typer.Exit(code=1) from exc
231
+ for path in written:
232
+ typer.echo(f"wrote {path}")
233
+
234
+
235
+ @app.command(name="update-assets")
236
+ def update_assets(
237
+ project_root: Path = typer.Option(
238
+ Path("."), "--project-root", help="Project root (default: current directory)."
239
+ ),
240
+ no_agents: bool = typer.Option(False, "--no-agents", help="Skip updating Claude agent files."),
241
+ no_shims: bool = typer.Option(False, "--no-shims", help="Skip updating language shims."),
242
+ lang: Optional[str] = typer.Option(
243
+ None, "--lang", help="Language for shims (auto-detected from .cdec/config.yaml if omitted)."
244
+ ),
245
+ ) -> None:
246
+ """Update code-constraints assets (Claude agents, shims) to the version bundled with
247
+ this installation. Run after upgrading code-constraints to pick up new agents or shim
248
+ changes in existing projects."""
249
+ from code_constraints.cli.scaffold import ScaffoldError, copy_agents, copy_shims, has_shim
250
+
251
+ updated_any = False
252
+
253
+ if not no_agents:
254
+ try:
255
+ dests = copy_agents(project_root, force=True)
256
+ for dest in dests:
257
+ typer.echo(f"updated {dest}")
258
+ updated_any = True
259
+ except ScaffoldError as exc:
260
+ typer.echo(str(exc), err=True)
261
+ raise typer.Exit(code=1) from exc
262
+
263
+ if not no_shims:
264
+ resolved_lang = lang
265
+ if resolved_lang is None:
266
+ config_file = project_root / ".cdec" / "config.yaml"
267
+ if config_file.is_file():
268
+ import yaml # type: ignore[import-untyped]
269
+ with config_file.open(encoding="utf-8") as f:
270
+ cfg = yaml.safe_load(f)
271
+ resolved_lang = (cfg or {}).get("language")
272
+ if resolved_lang and has_shim(resolved_lang):
273
+ try:
274
+ dests = copy_shims(project_root, resolved_lang, force=True)
275
+ for dest in dests:
276
+ typer.echo(f"updated {dest}")
277
+ updated_any = True
278
+ except ScaffoldError as exc:
279
+ typer.echo(str(exc), err=True)
280
+ raise typer.Exit(code=1) from exc
281
+
282
+ if not updated_any:
283
+ typer.echo("nothing to update (use --lang to specify a language for shims)")
284
+
285
+
286
+ @app.command()
287
+ def update(
288
+ branch: Optional[str] = typer.Option(
289
+ None, "--branch", help="Branch to update to (default: the currently checked-out branch)."
290
+ ),
291
+ no_frontend: bool = typer.Option(
292
+ False, "--no-frontend", help="Skip rebuilding the web UI (faster; leaves dist stale)."
293
+ ),
294
+ force: bool = typer.Option(
295
+ False, "--force", help="Reinstall everything even if dependencies are unchanged."
296
+ ),
297
+ ) -> None:
298
+ """Update this installation in place — equivalent to re-running the installer.
299
+
300
+ Pulls the latest code from GitHub, re-syncs Python dependencies (picking up
301
+ any requirement changes), and rebuilds the web frontend. The refreshed code
302
+ takes effect on the next `cdec` invocation.
303
+
304
+ Install steps whose inputs are unchanged since the last run are skipped; use
305
+ --force to reinstall regardless.
306
+ """
307
+ from code_constraints.cli.update import UpdateError, run_update
308
+
309
+ try:
310
+ repo_root = run_update(
311
+ branch=branch, frontend=not no_frontend, force=force, echo=typer.echo
312
+ )
313
+ except UpdateError as exc:
314
+ typer.echo(str(exc), err=True)
315
+ raise typer.Exit(code=1) from exc
316
+ typer.echo(f"\ncode-constraints updated at {repo_root}")
317
+
318
+
319
+ AUTO_ACCEPT_CHOICES = ("rules", "locks", "reference", "all")
320
+
321
+
322
+ @app.command()
323
+ def check(
324
+ config_dir: Path = typer.Option(
325
+ Path(".cdec"), "--config", help="`.cdec/` folder holding rules.yaml."
326
+ ),
327
+ source: Optional[Path] = typer.Option(
328
+ None, "--source", help="Override the source tree from rules.yaml."
329
+ ),
330
+ lang: Optional[str] = typer.Option(
331
+ None, "--lang", help="Override the language from rules.yaml."
332
+ ),
333
+ base_ref: Optional[str] = typer.Option(
334
+ None, "--base-ref", help="Git ref to use as the diff baseline (parsed live)."
335
+ ),
336
+ reference: Optional[Path] = typer.Option(
337
+ None, "--reference", help="Override the reference model (default: .cdec/reference.xmi)."
338
+ ),
339
+ repo: Path = typer.Option(Path("."), "--repo", help="Git repo root (only used with --base-ref)."),
340
+ format: str = typer.Option("human", "--format", help="human | json"),
341
+ json_out: Optional[Path] = typer.Option(None, "--json-out", help="Also write a JSON report."),
342
+ log_out: Optional[Path] = typer.Option(None, "--log-out", help="Also tee the human report to a file."),
343
+ fail_on: str = typer.Option("error", "--fail-on", help="error | warning | none"),
344
+ automatic_exceptions: list[str] = typer.Option(
345
+ [], "--automatic-exceptions", "-A", metavar="WHAT",
346
+ help=(
347
+ "Accept the code as it stands now instead of failing on it. "
348
+ "rules = grandfather every current violation into `exceptions:`; "
349
+ "locks = record digests for newly @locked code; "
350
+ "reference = re-snapshot reference.xmi; "
351
+ "all = every one of those. Repeatable or comma-separated."
352
+ ),
353
+ ),
354
+ force: bool = typer.Option(
355
+ False, "--force",
356
+ help="With `--automatic-exceptions locks`, also re-baseline implementations "
357
+ "that have CHANGED and drop released entries. This is the privileged "
358
+ "operation: it accepts a change to frozen code.",
359
+ ),
360
+ bypass_locks: bool = typer.Option(
361
+ False, "--bypass-locks",
362
+ help="Report lock violations but do not fail on them. Prints an audit banner "
363
+ "and sets summary.bypassed in the JSON report.",
364
+ ),
365
+ bypass_reason: str = typer.Option(
366
+ "", "--bypass-reason", help="Why locks are being bypassed (recorded in the output)."
367
+ ),
368
+ ) -> None:
369
+ """Check the project against every rule in `.cdec/rules.yaml`.
370
+
371
+ This is the whole gate. Configured architectural rules, source-tag
372
+ conformance, implementation locks and the reference-architecture gate are
373
+ all rule types in that one file, so there is one command to run, one report
374
+ to read, one exit code for CI, and one place to record an exception.
375
+ """
376
+ if format not in ("human", "json"):
377
+ raise typer.BadParameter(f"unknown format: {format}")
378
+ try:
379
+ fail_on_sev = Severity(fail_on)
380
+ except ValueError as exc:
381
+ raise typer.BadParameter(f"--fail-on must be error|warning|none, got {fail_on!r}") from exc
382
+ accept = _parse_auto_accept(automatic_exceptions)
383
+
384
+ cfg = _load_config_cli(config_dir)
385
+ source_path = source.resolve() if source else cfg.source
386
+ language = lang or cfg.language
387
+ if language not in SUPPORTED_LANGUAGES:
388
+ raise typer.BadParameter(f"unsupported language: {language}")
389
+ loaded = _load_rules_cli(config_dir)
390
+ _warn_about_legacy_files(config_dir)
391
+
392
+ from code_constraints.lint.engine import SourceContext
393
+
394
+ ctx_info = SourceContext(
395
+ source=source_path,
396
+ language=language,
397
+ config_dir=config_dir,
398
+ reference_path=reference or cfg.reference_path,
399
+ )
400
+
401
+ def run(*, filtered: bool) -> "Report":
402
+ """Parse, resolve the baseline, and run every rule. `filtered` applies
403
+ the recorded exceptions; the grandfathering pass needs the raw set."""
404
+ head_proj = _parse_project(source_path, language)
405
+ annotated, has_diff, base_proj = _resolve_baseline_and_diff(
406
+ head_proj=head_proj,
407
+ cfg_language=language,
408
+ config_dir=config_dir,
409
+ explicit_reference=reference,
410
+ explicit_base_ref=base_ref,
411
+ repo_path=repo,
412
+ default_reference=cfg.reference,
413
+ )
414
+ return run_checks(
415
+ annotated,
416
+ loaded.rules,
417
+ has_diff=has_diff,
418
+ baseline=load_baseline(config_dir) if filtered else None,
419
+ baseline_project=base_proj,
420
+ source_context=ctx_info,
421
+ bypass_locks=bypass_locks,
422
+ bypass_reason=bypass_reason,
423
+ )
424
+
425
+ if accept:
426
+ _apply_automatic_exceptions(accept, loaded, ctx_info, config_dir, run, force=force)
427
+ return
428
+
429
+ report = run(filtered=True)
430
+
431
+ human_text = report.to_human()
432
+ if format == "human":
433
+ typer.echo(human_text, nl=False)
434
+ else:
435
+ import json as _json
436
+
437
+ typer.echo(_json.dumps(report.to_json(), indent=2, sort_keys=True))
438
+
439
+ json_target = json_out or cfg.json_out
440
+ if json_target is not None:
441
+ report.write_json(json_target)
442
+ typer.echo(f"wrote {json_target}")
443
+
444
+ # The human report is what `cdec exceptions patch` expects to be handed
445
+ # back, marked up — so the log is that text verbatim, whatever --format said.
446
+ log_target = log_out or cfg.log_out
447
+ if log_target is not None:
448
+ log_target.parent.mkdir(parents=True, exist_ok=True)
449
+ log_target.write_text(human_text, encoding="utf-8")
450
+ typer.echo(f"wrote {log_target}")
451
+
452
+ if report.has_failures(fail_on_sev):
453
+ raise typer.Exit(code=1)
454
+
455
+
456
+ def _parse_auto_accept(values: list[str]) -> set[str]:
457
+ """Normalise `--automatic-exceptions` into the set of things to accept."""
458
+ out: set[str] = set()
459
+ for raw in values:
460
+ for part in str(raw).split(","):
461
+ item = part.strip().lower()
462
+ if not item:
463
+ continue
464
+ if item not in AUTO_ACCEPT_CHOICES:
465
+ raise typer.BadParameter(
466
+ f"--automatic-exceptions must be one of "
467
+ f"{', '.join(AUTO_ACCEPT_CHOICES)}; got {item!r}"
468
+ )
469
+ out.add(item)
470
+ if "all" in out:
471
+ out = {"rules", "locks", "reference"}
472
+ return out
473
+
474
+
475
+ def _apply_automatic_exceptions(
476
+ accept: set[str],
477
+ loaded: "LoadedRules",
478
+ ctx_info: "SourceContext",
479
+ config_dir: Path,
480
+ run: "Callable[..., Report]",
481
+ *,
482
+ force: bool,
483
+ ) -> None:
484
+ """Record the current state as approved, instead of failing on it.
485
+
486
+ Order matters. The rules that carry a baseline of their own (the reference
487
+ snapshot, the lock ledger) are settled first, then the checks are re-run,
488
+ and only what is *still* reported gets grandfathered into `exceptions:`. Do
489
+ it the other way round and you would write exceptions for issues the
490
+ re-snapshot was about to erase.
491
+ """
492
+ from code_constraints.lint.rules.base import RuleSkipped
493
+
494
+ ordered = (
495
+ ("reference", "reference-architecture"),
496
+ ("locks", "implementation-locks"),
497
+ )
498
+ for what, type_name in ordered:
499
+ if what not in accept:
500
+ continue
501
+ rules = loaded.of_type(type_name)
502
+ if not rules:
503
+ typer.echo(
504
+ f"--automatic-exceptions {what}: no `{type_name}` rule in rules.yaml, "
505
+ f"nothing to record."
506
+ )
507
+ continue
508
+ for rule in rules:
509
+ typer.echo(f"[{rule.rule_id}] recording the current state:")
510
+ try:
511
+ lines = rule.accept_current_state(_auto_accept_context(ctx_info), force=force)
512
+ except RuleSkipped as exc:
513
+ typer.echo(f" skipped: {exc}")
514
+ continue
515
+ for line in lines:
516
+ typer.echo(line)
517
+
518
+ if "rules" in accept:
519
+ report = run(filtered=False)
520
+ grandfathered = [v for v in report.violations if v.waivable]
521
+ path = write_baseline(config_dir, grandfathered)
522
+ typer.echo(
523
+ f"--automatic-exceptions rules: recorded {len(grandfathered)} exception(s) "
524
+ f"in {path}"
525
+ )
526
+ refused = [v for v in report.violations if not v.waivable]
527
+ if refused:
528
+ typer.echo(
529
+ f" {len(refused)} lock violation(s) were NOT grandfathered — a frozen "
530
+ f"implementation is accepted with "
531
+ f"`--automatic-exceptions locks --force`, never as an exception:"
532
+ )
533
+ for v in refused:
534
+ typer.echo(f" - [{v.key()}] {v.qualified_name}")
535
+
536
+
537
+ def _auto_accept_context(source_context: "SourceContext") -> "RuleContext":
538
+ """A minimal `RuleContext` for `accept_current_state`.
539
+
540
+ The baselining hooks re-read the source themselves and never look at the
541
+ model, so there is no reason to parse one just to hand it over.
542
+ """
543
+ from code_constraints.core.model import Project
544
+ from code_constraints.lint.rules.base import RuleContext
545
+
546
+ language = cast(SourceLanguage, source_context.language or "python")
547
+ return RuleContext(
548
+ project=Project(source_language=language),
549
+ has_diff=False,
550
+ source=source_context.source,
551
+ language=source_context.language,
552
+ config_dir=source_context.config_dir,
553
+ reference_path=source_context.reference_path,
554
+ )
555
+
556
+
557
+ def _warn_about_legacy_files(config_dir: Path) -> None:
558
+ """Point out per-concern files that `rules.yaml` has superseded."""
559
+ from code_constraints.lint.config import legacy_files
560
+
561
+ stale = legacy_files(config_dir)
562
+ if not stale:
563
+ return
564
+ names = ", ".join(p.name for p in stale)
565
+ typer.echo(
566
+ f"note: {names} in {config_dir} are the old per-concern files. They are still "
567
+ f"read, but everything now lives in rules.yaml — fold them in with "
568
+ f"`cdec init --migrate`.",
569
+ err=True,
570
+ )
571
+
572
+
573
+ # ---------------------------------------------------------------------------
574
+ # Retired commands.
575
+ #
576
+ # `enforce`, `lock` and `reference test` were three more gates with three more
577
+ # reports and three more exit codes. They are rule types in `rules.yaml` now and
578
+ # run inside `cdec check`. Typer would answer an old invocation with "No such
579
+ # command", which tells a user nothing, so each one survives as a hidden stub
580
+ # that says where the behaviour went.
581
+ # ---------------------------------------------------------------------------
582
+
583
+ _RETIRED = {
584
+ "enforce": (
585
+ "`cdec enforce` is now the `tag-conformance` rule type, checked by "
586
+ "`cdec check`.\n"
587
+ "Add this to .cdec/rules.yaml:\n"
588
+ " - id: tags-must-be-honoured\n"
589
+ " type: tag-conformance\n"
590
+ " severity: error\n"
591
+ "then run `cdec check`."
592
+ ),
593
+ "lock": (
594
+ "`cdec lock` is now the `implementation-locks` rule type, checked by "
595
+ "`cdec check`.\n"
596
+ "Add this to .cdec/rules.yaml:\n"
597
+ " - id: frozen-implementations\n"
598
+ " type: implementation-locks\n"
599
+ " severity: error\n"
600
+ "then:\n"
601
+ " cdec check # verify (was: lock check)\n"
602
+ " cdec check --automatic-exceptions locks # baseline (was: lock set)\n"
603
+ " cdec check --automatic-exceptions locks --force # re-baseline (was: --force)\n"
604
+ "The ledger lives in the `locks:` section of rules.yaml; "
605
+ "`cdec init --migrate` folds an existing .cdec/locks.yaml in."
606
+ ),
607
+ }
608
+
609
+
610
+ def _retired(name: str) -> None:
611
+ typer.echo(_RETIRED[name], err=True)
612
+ raise typer.Exit(code=2)
613
+
614
+
615
+ _PASSTHROUGH = {"ignore_unknown_options": True, "allow_extra_args": True}
616
+
617
+
618
+ @app.command(hidden=True, context_settings=_PASSTHROUGH)
619
+ def enforce(ctx: typer.Context) -> None:
620
+ """Retired — see `cdec check` and the `tag-conformance` rule type."""
621
+ _retired("enforce")
622
+
623
+
624
+ @app.command(hidden=True, context_settings=_PASSTHROUGH)
625
+ def lock(ctx: typer.Context) -> None:
626
+ """Retired — see `cdec check` and the `implementation-locks` rule type."""
627
+ _retired("lock")
628
+
629
+
630
+ # ---------------------------------------------------------------------------
631
+ # `cdec exceptions` — the review loop.
632
+ #
633
+ # Enforcement that can only say "no" gets switched off. These commands are the
634
+ # other half: read the report, decide which issues are acceptable, record the
635
+ # decision (with a reason) in the `exceptions:` section of `.cdec/rules.yaml`,
636
+ # and keep moving. Every issue prints a stable key, so a decision can be quoted
637
+ # by a human editing a text file or by an agent passing a key on the command
638
+ # line — the two paths resolve to exactly the same operation.
639
+ # ---------------------------------------------------------------------------
640
+
641
+ exceptions_app = typer.Typer(
642
+ help=(
643
+ "Accept known violations, with a reason. `review` writes an editable "
644
+ "report, `patch` applies the lines you marked [ALLOW], `allow`/`remove` "
645
+ "take keys directly, `prune` drops the ones that no longer apply."
646
+ )
647
+ )
648
+ app.add_typer(exceptions_app, name="exceptions")
649
+ # `baseline` was the old name for this group, back when the decisions lived in
650
+ # their own file. Kept as a hidden alias so existing scripts and muscle memory
651
+ # keep working.
652
+ app.add_typer(exceptions_app, name="baseline", hidden=True)
653
+
654
+
655
+ @exceptions_app.command("review")
656
+ def exceptions_review(
657
+ config_dir: Path = typer.Option(Path(".cdec"), "--config", help="`.cdec/` folder."),
658
+ out: Optional[Path] = typer.Option(
659
+ None, "--out", "-o", help="Write the review file here (default: stdout)."
660
+ ),
661
+ source: Optional[Path] = typer.Option(None, "--source", help="Override source tree."),
662
+ reference: Optional[Path] = typer.Option(None, "--reference", help="Override the reference model."),
663
+ base_ref: Optional[str] = typer.Option(None, "--base-ref", help="Git ref to use as baseline."),
664
+ repo: Path = typer.Option(Path("."), "--repo", help="Git repo root (only used with --base-ref)."),
665
+ include_waived: bool = typer.Option(
666
+ False, "--all", help="Include already-accepted issues (mark them [REMOVE] to withdraw)."
667
+ ),
668
+ format: str = typer.Option("text", "--format", help="text | json"),
669
+ ) -> None:
670
+ """Write every current issue as one markable line per issue.
671
+
672
+ Mark the ones you accept with `[ALLOW]` (optionally `[ALLOW: reason]`) and
673
+ feed the file back through `cdec exceptions patch`.
674
+ """
675
+ if format not in ("text", "json"):
676
+ raise typer.BadParameter(f"unknown format: {format}")
677
+ collected = _collect_issues_cli(
678
+ config_dir, source=source, reference=reference, base_ref=base_ref, repo=repo
679
+ )
680
+ if format == "json":
681
+ import json as _json
682
+
683
+ payload = _json.dumps(
684
+ {
685
+ "issues": [_issue_to_json(i) for i in collected.issues
686
+ if include_waived or not i.waived],
687
+ "skipped": [{"rule": r, "reason": reason} for r, reason in collected.skipped],
688
+ },
689
+ indent=2,
690
+ sort_keys=True,
691
+ )
692
+ _write_or_echo(payload + "\n", out)
693
+ return
694
+
695
+ from code_constraints.waivers import render_review
696
+
697
+ text = render_review(collected.issues, include_waived=include_waived)
698
+ for rule_id, reason in collected.skipped:
699
+ text += f"# skipped: {rule_id}: {reason}\n"
700
+ _write_or_echo(text, out)
701
+
702
+
703
+ @exceptions_app.command("patch")
704
+ def exceptions_patch(
705
+ file: Path = typer.Option(
706
+ ..., "--file", "-f",
707
+ help="Reviewed report. Use '-' to read from stdin.",
708
+ ),
709
+ config_dir: Path = typer.Option(Path(".cdec"), "--config", help="`.cdec/` folder."),
710
+ reason: str = typer.Option(
711
+ "", "--reason", help="Reason applied to lines that don't carry [ALLOW: …]."
712
+ ),
713
+ source: Optional[Path] = typer.Option(None, "--source", help="Override source tree."),
714
+ reference: Optional[Path] = typer.Option(None, "--reference", help="Override the reference model."),
715
+ base_ref: Optional[str] = typer.Option(None, "--base-ref", help="Git ref to use as baseline."),
716
+ repo: Path = typer.Option(Path("."), "--repo", help="Git repo root (only used with --base-ref)."),
717
+ dry_run: bool = typer.Option(False, "--dry-run", help="Report what would change; write nothing."),
718
+ ignore_unknown: bool = typer.Option(
719
+ False, "--ignore-unknown",
720
+ help="Don't fail on keys that match no current issue (e.g. a stale report).",
721
+ ),
722
+ ) -> None:
723
+ """Apply the `[ALLOW]` / `[REMOVE]` marks in a reviewed report.
724
+
725
+ Any text file works — the output of `cdec exceptions review`, of
726
+ `cdec check --log-out`, or a hand-written list — because the parser only
727
+ looks for a marker and an issue key on the same line.
728
+ """
729
+ text = _read_review_text(file)
730
+
731
+ from code_constraints.waivers import apply_decisions, parse_review, save_waivers
732
+
733
+ decisions = parse_review(text)
734
+ if decisions.empty and not decisions.problems:
735
+ typer.echo(
736
+ "no [ALLOW] or [REMOVE] marks found — nothing to apply.\n"
737
+ "Mark a line by adding [ALLOW] anywhere on it."
738
+ )
739
+ return
740
+
741
+ collected = _collect_issues_cli(
742
+ config_dir, source=source, reference=reference, base_ref=base_ref, repo=repo
743
+ )
744
+ result = apply_decisions(
745
+ collected.store, collected, decisions, default_reason=reason
746
+ )
747
+ _report_apply(result, collected.ledger_path, dry_run=dry_run)
748
+ if result.changed and not dry_run:
749
+ save_waivers(config_dir, collected.store)
750
+ _exit_on_apply_failure(result, ignore_unknown=ignore_unknown)
751
+
752
+
753
+ @exceptions_app.command("allow")
754
+ def exceptions_allow(
755
+ keys: list[str] = typer.Argument(..., help="Issue keys, e.g. V-1A2B3C4D."),
756
+ config_dir: Path = typer.Option(Path(".cdec"), "--config", help="`.cdec/` folder."),
757
+ reason: str = typer.Option("", "--reason", help="Why this issue is acceptable."),
758
+ source: Optional[Path] = typer.Option(None, "--source", help="Override source tree."),
759
+ reference: Optional[Path] = typer.Option(None, "--reference", help="Override the reference model."),
760
+ base_ref: Optional[str] = typer.Option(None, "--base-ref", help="Git ref to use as baseline."),
761
+ repo: Path = typer.Option(Path("."), "--repo", help="Git repo root (only used with --base-ref)."),
762
+ dry_run: bool = typer.Option(False, "--dry-run", help="Report what would change; write nothing."),
763
+ ) -> None:
764
+ """Accept issues by key — the path an agent or a one-liner takes.
765
+
766
+ The keys must name issues that are reported right now; a key that matches
767
+ nothing is an error, not a silent no-op, because it almost always means the
768
+ report being quoted is stale.
769
+ """
770
+ from code_constraints.waivers import allow_keys, save_waivers
771
+
772
+ collected = _collect_issues_cli(
773
+ config_dir, source=source, reference=reference, base_ref=base_ref, repo=repo
774
+ )
775
+ result = allow_keys(collected.store, collected, keys, reason=reason)
776
+ _report_apply(result, collected.ledger_path, dry_run=dry_run)
777
+ if result.changed and not dry_run:
778
+ save_waivers(config_dir, collected.store)
779
+ _exit_on_apply_failure(result)
780
+
781
+
782
+ @exceptions_app.command("remove")
783
+ def exceptions_remove(
784
+ keys: list[str] = typer.Argument(..., help="Issue keys to stop allowing."),
785
+ config_dir: Path = typer.Option(Path(".cdec"), "--config", help="`.cdec/` folder."),
786
+ dry_run: bool = typer.Option(False, "--dry-run", help="Report what would change; write nothing."),
787
+ ) -> None:
788
+ """Withdraw exceptions by key, so the issue blocks again.
789
+
790
+ Needs no source parse: the ledger alone identifies what to drop, which
791
+ means an exception can always be withdrawn even if the code no longer parses.
792
+ """
793
+ from code_constraints.waivers import remove_keys, save_waivers
794
+
795
+ store = _load_waivers_cli(config_dir)
796
+ result = remove_keys(store, keys)
797
+ _report_apply(result, _ledger_path(config_dir), dry_run=dry_run)
798
+ if result.changed and not dry_run:
799
+ save_waivers(config_dir, store)
800
+ if result.malformed or result.not_waived:
801
+ raise typer.Exit(code=1)
802
+
803
+
804
+ @exceptions_app.command("list")
805
+ def exceptions_list(
806
+ config_dir: Path = typer.Option(Path(".cdec"), "--config", help="`.cdec/` folder."),
807
+ engine: Optional[str] = typer.Option(
808
+ None, "--engine", help="Only show one engine's exceptions: check | enforce | reference."
809
+ ),
810
+ format: str = typer.Option("human", "--format", help="human | json"),
811
+ ) -> None:
812
+ """Show what is currently accepted, and why."""
813
+ if format not in ("human", "json"):
814
+ raise typer.BadParameter(f"unknown format: {format}")
815
+ ledger = _ledger_path(config_dir)
816
+ store = _load_waivers_cli(config_dir)
817
+ waivers = [w for w in store.waivers if engine is None or w.engine == engine]
818
+
819
+ if format == "json":
820
+ import json as _json
821
+
822
+ typer.echo(
823
+ _json.dumps(
824
+ [
825
+ {
826
+ "key": w.key,
827
+ "engine": w.engine,
828
+ "rule": w.rule,
829
+ "qualifiedName": w.qualified_name,
830
+ "detail": w.detail,
831
+ "reason": w.reason,
832
+ "added": w.added,
833
+ "addedBy": w.added_by,
834
+ }
835
+ for w in waivers
836
+ ],
837
+ indent=2,
838
+ sort_keys=True,
839
+ )
840
+ )
841
+ return
842
+
843
+ if not waivers:
844
+ typer.echo(f"no exceptions recorded in {ledger}.")
845
+ return
846
+ typer.echo(f"{len(waivers)} exception(s) in {ledger}:")
847
+ for w in sorted(waivers, key=lambda w: (w.engine, w.rule, w.qualified_name, w.detail)):
848
+ detail = f" {w.detail}" if w.detail else ""
849
+ typer.echo(f" - [{w.key}] [{w.engine}/{w.rule}] {w.qualified_name}{detail}")
850
+ meta = ", ".join(
851
+ part for part in (
852
+ f"reason: {w.reason}" if w.reason else "",
853
+ f"added: {w.added}" if w.added else "",
854
+ f"by: {w.added_by}" if w.added_by else "",
855
+ ) if part
856
+ )
857
+ if meta:
858
+ typer.echo(f" {meta}")
859
+
860
+
861
+ @exceptions_app.command("prune")
862
+ def exceptions_prune(
863
+ config_dir: Path = typer.Option(Path(".cdec"), "--config", help="`.cdec/` folder."),
864
+ source: Optional[Path] = typer.Option(None, "--source", help="Override source tree."),
865
+ reference: Optional[Path] = typer.Option(None, "--reference", help="Override the reference model."),
866
+ base_ref: Optional[str] = typer.Option(None, "--base-ref", help="Git ref to use as baseline."),
867
+ repo: Path = typer.Option(Path("."), "--repo", help="Git repo root (only used with --base-ref)."),
868
+ dry_run: bool = typer.Option(False, "--dry-run", help="Report what would change; write nothing."),
869
+ ) -> None:
870
+ """Drop exceptions for issues that no longer occur.
871
+
872
+ An exception outlives the code it was granted for, and a stale one silently
873
+ pre-approves a future violation of the same rule on the same element. This
874
+ only prunes engines that actually ran, so a skipped rule never looks like a
875
+ clean one.
876
+ """
877
+ from code_constraints.waivers import prune as prune_waivers
878
+ from code_constraints.waivers import save_waivers
879
+
880
+ collected = _collect_issues_cli(
881
+ config_dir, source=source, reference=reference, base_ref=base_ref, repo=repo
882
+ )
883
+ stale = prune_waivers(collected.store, collected)
884
+ if not stale:
885
+ typer.echo("no stale exceptions.")
886
+ return
887
+ verb = "would drop" if dry_run else "dropped"
888
+ typer.echo(f"{verb} {len(stale)} stale exception(s):")
889
+ for w in stale:
890
+ detail = f" {w.detail}" if w.detail else ""
891
+ typer.echo(f" - [{w.key}] [{w.engine}/{w.rule}] {w.qualified_name}{detail}")
892
+ if not dry_run:
893
+ save_waivers(config_dir, collected.store)
894
+ typer.echo(f"wrote {collected.ledger_path}")
895
+
896
+
897
+ serve_app = typer.Typer(
898
+ invoke_without_command=True,
899
+ help="Run the local web viewer (FastAPI + Svelte SPA).",
900
+ )
901
+ app.add_typer(serve_app, name="serve")
902
+
903
+
904
+ @serve_app.callback()
905
+ def _serve_default(
906
+ ctx: typer.Context,
907
+ host: str = typer.Option("127.0.0.1", "--host"),
908
+ port: int = typer.Option(8765, "--port"),
909
+ ) -> None:
910
+ """Start the web viewer. `cdec serve parse` parses the CWD and opens it."""
911
+ # A subcommand (e.g. `parse`) was given — let it handle everything.
912
+ if ctx.invoked_subcommand is not None:
913
+ return
914
+ _run_server(host, port)
915
+
916
+
917
+ @serve_app.command("parse")
918
+ def serve_parse(
919
+ path: Path = typer.Argument(
920
+ Path("."),
921
+ exists=True,
922
+ file_okay=False,
923
+ dir_okay=True,
924
+ help="Source tree to parse (defaults to the current directory).",
925
+ ),
926
+ lang: Optional[str] = typer.Option(
927
+ None,
928
+ "--lang",
929
+ help="python | csharp | typescript | svelte | odin | lua | julia. Auto-detected if omitted.",
930
+ ),
931
+ host: str = typer.Option("127.0.0.1", "--host"),
932
+ port: int = typer.Option(8765, "--port", help="Pass after `parse`, e.g. `serve parse --port 9000`."),
933
+ ) -> None:
934
+ """Start the viewer and open straight to the class diagram of a codebase.
935
+
936
+ Shortcut for the parse-then-view flow: resolves the language (auto-detect
937
+ when `--lang` is omitted, based on the file mix — any `.svelte` file wins,
938
+ otherwise the most common of .py/.cs/.ts), then launches the server and
939
+ opens the browser deep-linked to the rendered UML class diagram.
940
+ """
941
+ root = path.resolve()
942
+ chosen = lang or detect_language(root)
943
+ if chosen is None:
944
+ raise typer.BadParameter(
945
+ f"could not auto-detect a language under {root}; pass --lang explicitly"
946
+ )
947
+ if chosen not in SUPPORTED_LANGUAGES:
948
+ raise typer.BadParameter(f"unsupported language: {chosen}")
949
+
950
+ url = f"http://{host}:{port}/?path={quote(str(root))}&lang={chosen}"
951
+ typer.echo(f"serving {root} as {chosen}; opening the class diagram at {url}")
952
+ _run_server(host, port, open_url=url)
953
+
954
+
955
+ @app.command()
956
+ def propose(
957
+ model: Path = typer.Argument(
958
+ ..., exists=True, dir_okay=False,
959
+ help="Proposed target architecture (.json or .xmi), e.g. authored by an agent.",
960
+ ),
961
+ source: Optional[Path] = typer.Option(
962
+ None, "--source", exists=True, file_okay=False, dir_okay=True,
963
+ help="Source tree the proposal is for (falls back to .cdec/config.yaml).",
964
+ ),
965
+ lang: Optional[str] = typer.Option(
966
+ None, "--lang", help="python | csharp | typescript | svelte | odin | lua | julia (auto-detected if omitted)."
967
+ ),
968
+ config_dir: Path = typer.Option(
969
+ Path(".cdec"), "--config", help="`.cdec/` folder used to resolve omitted args."
970
+ ),
971
+ against: str = typer.Option(
972
+ "source", "--against",
973
+ help=(
974
+ "Baseline for the diff: source (current code) | "
975
+ "reference (.cdec/reference.xmi) | none."
976
+ ),
977
+ ),
978
+ focus: str = typer.Option(
979
+ "", "--focus",
980
+ help="Comma-separated qualified class names; the viewer pre-filters to these.",
981
+ ),
982
+ host: str = typer.Option("127.0.0.1", "--host"),
983
+ port: int = typer.Option(8765, "--port"),
984
+ no_browser: bool = typer.Option(
985
+ False, "--no-browser", help="Don't open a browser tab; just push and print the URL."
986
+ ),
987
+ ) -> None:
988
+ """Push a proposed architecture to the web viewer, diffed against a baseline.
989
+
990
+ The review loop this enables: an agent writes/edits a model file (JSON is
991
+ easiest), runs `cdec propose model.json --focus Billing,Invoice`, and the
992
+ browser shows the proposal as a diff (green = still to build, red = to be
993
+ removed). Re-running `propose` after editing the model refreshes any open
994
+ viewer tab in place — no new tabs, no manual reload. Once agreed, lock it
995
+ with `cdec reference set model.json`.
996
+
997
+ If a `cdec serve` instance is already running on the port it is reused;
998
+ otherwise a server is started (blocking) with the proposal pre-loaded.
999
+ """
1000
+ if against not in ("source", "reference", "none"):
1001
+ raise typer.BadParameter("--against must be source|reference|none")
1002
+
1003
+ source_path, chosen_lang, ref_path = _resolve_reference_inputs(
1004
+ source, lang, None, config_dir
1005
+ )
1006
+ proposal_proj = _load_model_cli(model)
1007
+ focus_q = quote(focus) if focus else ""
1008
+
1009
+ from code_constraints.core.editor_io import project_to_json
1010
+
1011
+ base_url = f"http://{host}:{port}"
1012
+
1013
+ # --- fast path: a server is already running; push over HTTP and let any
1014
+ # open viewer tab hot-swap via its proposal polling.
1015
+ if _server_running(base_url):
1016
+ payload = project_to_json(proposal_proj)
1017
+ try:
1018
+ proj_info = _http_json(
1019
+ "POST", f"{base_url}/api/projects",
1020
+ {"path": str(source_path), "lang": chosen_lang},
1021
+ )
1022
+ push_url = (
1023
+ f"{base_url}/api/projects/{proj_info['id']}/proposal"
1024
+ f"?against={against}&focus={focus_q}"
1025
+ )
1026
+ result = _http_json("POST", push_url, payload)
1027
+ except Exception as exc:
1028
+ typer.echo(f"pushing proposal to running server failed: {exc}", err=True)
1029
+ raise typer.Exit(code=1) from exc
1030
+ view_url = (
1031
+ f"{base_url}/?xmi={result['id']}&project={result['project_id']}"
1032
+ f"&path={quote(f'proposal: {model}')}&lang={chosen_lang}&proposal=1"
1033
+ + (f"&focus={focus_q}" if focus_q else "")
1034
+ )
1035
+ typer.echo(
1036
+ f"pushed proposal #{result['seq']} for {source_path} (baseline: {against})"
1037
+ )
1038
+ if result["seq"] > 1:
1039
+ typer.echo("an open viewer tab will refresh automatically; URL:")
1040
+ typer.echo(view_url)
1041
+ if result["seq"] == 1 and not no_browser:
1042
+ import webbrowser
1043
+
1044
+ webbrowser.open(view_url)
1045
+ typer.echo(f"when agreed, lock it with: cdec reference set {model}")
1046
+ return
1047
+
1048
+ # --- no server yet: compute the diff in-process, pre-register it, and
1049
+ # start the server with the browser deep-linked to the proposal.
1050
+ import hashlib
1051
+ import uuid
1052
+
1053
+ if against == "source":
1054
+ baseline = _parse_project(source_path, chosen_lang)
1055
+ elif against == "reference":
1056
+ if not ref_path.is_file():
1057
+ typer.echo(f"reference XMI not found: {ref_path}", err=True)
1058
+ raise typer.Exit(code=2)
1059
+ baseline = _load_model_cli(ref_path)
1060
+ else:
1061
+ baseline = None
1062
+
1063
+ if baseline is not None:
1064
+ try:
1065
+ annotated = diff_projects(baseline, proposal_proj)
1066
+ except ValueError as exc:
1067
+ typer.echo(str(exc), err=True)
1068
+ raise typer.Exit(code=2) from exc
1069
+ else:
1070
+ annotated = proposal_proj
1071
+
1072
+ from code_constraints.web.app import ProjectInfo, XmiInfo, _project_cache, _registry
1073
+
1074
+ project_id = hashlib.sha1(
1075
+ f"{source_path}|{chosen_lang}".encode("utf-8")
1076
+ ).hexdigest()[:12]
1077
+ xmi_id = uuid.uuid4().hex[:12]
1078
+ write_project(annotated, _project_cache(project_id) / f"{xmi_id}.xmi")
1079
+ _registry.register(
1080
+ ProjectInfo(id=project_id, path=str(source_path), lang=chosen_lang)
1081
+ )
1082
+ _registry.register_xmi(XmiInfo(id=xmi_id, project_id=project_id))
1083
+ focus_list = [f.strip() for f in focus.split(",") if f.strip()]
1084
+ _registry.record_proposal(project_id, xmi_id, focus_list)
1085
+
1086
+ view_url = (
1087
+ f"{base_url}/?xmi={xmi_id}&project={project_id}"
1088
+ f"&path={quote(f'proposal: {model}')}&lang={chosen_lang}&proposal=1"
1089
+ + (f"&focus={focus_q}" if focus_q else "")
1090
+ )
1091
+ typer.echo(f"proposal loaded (baseline: {against}); serving at {view_url}")
1092
+ typer.echo(f"iterate with: cdec propose {model} (refreshes the open tab)")
1093
+ typer.echo(f"when agreed, lock it with: cdec reference set {model}")
1094
+ _run_server(host, port, open_url=None if no_browser else view_url)
1095
+
1096
+
1097
+ reference_app = typer.Typer(
1098
+ help=(
1099
+ "Work with the architecture reference model. Testing the code against "
1100
+ "it is the `reference-architecture` rule type, run by `cdec check`; "
1101
+ "these commands author and visualise it."
1102
+ )
1103
+ )
1104
+ app.add_typer(reference_app, name="reference")
1105
+
1106
+
1107
+ @reference_app.command("set")
1108
+ def reference_set(
1109
+ model: Path = typer.Argument(
1110
+ ..., exists=True, dir_okay=False,
1111
+ help="Model file to promote (.json or .xmi) — e.g. an agreed proposal.",
1112
+ ),
1113
+ reference: Optional[Path] = typer.Option(
1114
+ None, "--reference", help="Destination reference file (default: .cdec/reference.xmi)."
1115
+ ),
1116
+ config_dir: Path = typer.Option(
1117
+ Path(".cdec"), "--config", help="`.cdec/` folder used to resolve the destination."
1118
+ ),
1119
+ ) -> None:
1120
+ """Lock an authored model in as the target architecture.
1121
+
1122
+ Takes a hand-written / agent-written model file (JSON or XMI) and writes it
1123
+ to the project's reference model, so a `reference-architecture` rule starts
1124
+ constraining development against it immediately. This is the "accept the
1125
+ proposal" step of the propose -> review -> lock workflow.
1126
+
1127
+ The other way to produce a reference is to snapshot the code as it stands:
1128
+ `cdec check --automatic-exceptions reference`. The distinction is the point.
1129
+ `set` declares what the code *should become*; the snapshot records what it
1130
+ *is*.
1131
+ """
1132
+ proj = _load_model_cli(model)
1133
+ if reference is not None:
1134
+ ref_path = reference
1135
+ else:
1136
+ try:
1137
+ cfg = load_project_config(config_dir)
1138
+ ref_path = cfg.reference_path
1139
+ except ConfigError:
1140
+ ref_path = config_dir / REFERENCE_FILENAME
1141
+ ref_path.parent.mkdir(parents=True, exist_ok=True)
1142
+ _save_model_cli(proj, ref_path)
1143
+ n_classes = sum(1 for _ in proj.iter_classes())
1144
+ typer.echo(f"locked {model} as target architecture -> {ref_path} ({n_classes} classes)")
1145
+
1146
+
1147
+ @reference_app.command("show")
1148
+ def reference_show(
1149
+ source: Optional[Path] = typer.Argument(
1150
+ None,
1151
+ exists=True,
1152
+ file_okay=False,
1153
+ dir_okay=True,
1154
+ help="Source tree to compare (falls back to the `source` in rules.yaml).",
1155
+ ),
1156
+ reference: Optional[Path] = typer.Option(
1157
+ None, "--reference", help="Reference model (default: .cdec/reference.xmi)."
1158
+ ),
1159
+ lang: Optional[str] = typer.Option(
1160
+ None, "--lang", help="python | csharp | typescript | svelte | odin | lua | julia (auto-detected if omitted)."
1161
+ ),
1162
+ config_dir: Path = typer.Option(
1163
+ Path(".cdec"), "--config", help="`.cdec/` folder used to resolve omitted args."
1164
+ ),
1165
+ host: str = typer.Option("127.0.0.1", "--host"),
1166
+ port: int = typer.Option(8765, "--port"),
1167
+ ) -> None:
1168
+ """Open the web viewer on a diff of the current code against the reference."""
1169
+ import uuid
1170
+
1171
+ source_path, chosen_lang, ref_path = _resolve_reference_inputs(
1172
+ source, lang, reference, config_dir
1173
+ )
1174
+ if not ref_path.is_file():
1175
+ typer.echo(
1176
+ f"reference model not found: {ref_path}\n"
1177
+ f"Snapshot the current architecture with "
1178
+ f"`cdec check --automatic-exceptions reference`, or promote an authored "
1179
+ f"model with `cdec reference set <model>`.",
1180
+ err=True,
1181
+ )
1182
+ raise typer.Exit(code=2)
1183
+
1184
+ reference_proj = _load_model_cli(ref_path)
1185
+ current = _parse_project(source_path, chosen_lang)
1186
+ try:
1187
+ # The reference is the *target* architecture and the codebase is the
1188
+ # *current* state, so the codebase is the old side and the reference the
1189
+ # new side: elements only in the reference render as green additions
1190
+ # ("the code still needs to grow this"), elements only in the code as
1191
+ # red removals. This is intentionally the inverse of `diff-vs-xmi` and of
1192
+ # the `reference-architecture` rule, where the reference is the old
1193
+ # baseline.
1194
+ annotated = diff_projects(current, reference_proj)
1195
+ except ValueError as exc:
1196
+ typer.echo(str(exc), err=True)
1197
+ raise typer.Exit(code=2) from exc
1198
+
1199
+ # Pre-register the annotated diff in the in-process web registry (uvicorn
1200
+ # imports `code_constraints.web.app` in this same process, so the module-level registry
1201
+ # is shared). The `u`-prefixed project id marks it synthetic in the UI.
1202
+ from code_constraints.web.app import ProjectInfo, XmiInfo, _project_cache, _registry
1203
+
1204
+ project_id = "u" + uuid.uuid4().hex[:11]
1205
+ xmi_id = uuid.uuid4().hex[:12]
1206
+ label = f"reference diff: {source_path} -> {ref_path.name}"
1207
+ write_project(annotated, _project_cache(project_id) / f"{xmi_id}.xmi")
1208
+ # Register the *real* source directory as the project path so the views /
1209
+ # git / layer endpoints (which resolve `.cdec/` via `_find_cdec_dir(info.path)`)
1210
+ # work. The human-readable `label` is only for display and rides the URL.
1211
+ _registry.register(ProjectInfo(id=project_id, path=str(source_path), lang=chosen_lang))
1212
+ _registry.register_xmi(XmiInfo(id=xmi_id, project_id=project_id))
1213
+
1214
+ url = (
1215
+ f"http://{host}:{port}/?xmi={xmi_id}&project={project_id}"
1216
+ f"&path={quote(label)}&lang={chosen_lang}"
1217
+ )
1218
+ typer.echo(f"comparing {source_path} (current) against target {ref_path}; opening {url}")
1219
+ _run_server(host, port, open_url=url)
1220
+
1221
+
1222
+ @reference_app.command("test", hidden=True, context_settings=_PASSTHROUGH)
1223
+ def reference_test(ctx: typer.Context) -> None:
1224
+ """Retired — see `cdec check` and the `reference-architecture` rule type."""
1225
+ typer.echo(
1226
+ "`cdec reference test` is now the `reference-architecture` rule type, checked "
1227
+ "by `cdec check`.\n"
1228
+ "Add this to .cdec/rules.yaml:\n"
1229
+ " - id: public-shape-is-frozen\n"
1230
+ " type: reference-architecture\n"
1231
+ " severity: error\n"
1232
+ "then run `cdec check`.",
1233
+ err=True,
1234
+ )
1235
+ raise typer.Exit(code=2)
1236
+
1237
+
1238
+ @reference_app.command("update", hidden=True, context_settings=_PASSTHROUGH)
1239
+ def reference_update(ctx: typer.Context) -> None:
1240
+ """Retired — see `cdec check --automatic-exceptions reference`."""
1241
+ typer.echo(
1242
+ "`cdec reference update` is now "
1243
+ "`cdec check --automatic-exceptions reference`, which re-snapshots "
1244
+ ".cdec/reference.xmi from the current source.",
1245
+ err=True,
1246
+ )
1247
+ raise typer.Exit(code=2)
1248
+
1249
+
1250
+ # ---------- helpers ----------
1251
+
1252
+ def _server_running(base_url: str) -> bool:
1253
+ """True if a code-constraints server answers at `base_url`."""
1254
+ import urllib.error
1255
+ import urllib.request
1256
+
1257
+ try:
1258
+ with urllib.request.urlopen(f"{base_url}/api/projects", timeout=1.5):
1259
+ return True
1260
+ except (urllib.error.URLError, OSError, TimeoutError):
1261
+ return False
1262
+
1263
+
1264
+ def _http_json(method: str, url: str, payload: Optional[dict] = None) -> dict:
1265
+ """Minimal JSON-over-HTTP client (stdlib only). Raises on non-2xx."""
1266
+ import json as _json
1267
+ import urllib.error
1268
+ import urllib.request
1269
+
1270
+ data = _json.dumps(payload).encode("utf-8") if payload is not None else None
1271
+ req = urllib.request.Request(url, data=data, method=method)
1272
+ if data is not None:
1273
+ req.add_header("content-type", "application/json")
1274
+ try:
1275
+ with urllib.request.urlopen(req, timeout=120) as resp:
1276
+ body = resp.read()
1277
+ except urllib.error.HTTPError as exc:
1278
+ detail = exc.read().decode("utf-8", errors="replace")
1279
+ raise RuntimeError(f"{exc.code} {exc.reason}: {detail}") from exc
1280
+ return _json.loads(body or b"null")
1281
+
1282
+
1283
+ def _load_model_cli(path: Path):
1284
+ """`load_model` with typer-friendly error reporting."""
1285
+ try:
1286
+ return load_model(path)
1287
+ except UnsupportedModelFormat as exc:
1288
+ typer.echo(str(exc), err=True)
1289
+ raise typer.Exit(code=2) from exc
1290
+ except Exception as exc:
1291
+ typer.echo(f"could not read model {path}: {exc}", err=True)
1292
+ raise typer.Exit(code=2) from exc
1293
+
1294
+
1295
+ def _save_model_cli(project, path: Path) -> None:
1296
+ try:
1297
+ save_model(project, path)
1298
+ except UnsupportedModelFormat as exc:
1299
+ typer.echo(str(exc), err=True)
1300
+ raise typer.Exit(code=2) from exc
1301
+
1302
+
1303
+ # ---------- config helpers ----------
1304
+
1305
+ def _load_config_cli(config_dir: Path):
1306
+ """`load_project_config` with typer-friendly error reporting."""
1307
+ try:
1308
+ return load_project_config(config_dir)
1309
+ except ConfigError as exc:
1310
+ typer.echo(str(exc), err=True)
1311
+ raise typer.Exit(code=2) from exc
1312
+
1313
+
1314
+ def _load_rules_cli(config_dir: Path):
1315
+ """`load_rules` with typer-friendly error reporting."""
1316
+ try:
1317
+ return load_rules(config_dir)
1318
+ except ConfigError as exc:
1319
+ typer.echo(str(exc), err=True)
1320
+ raise typer.Exit(code=2) from exc
1321
+
1322
+
1323
+ def _ledger_path(config_dir: Path) -> Path:
1324
+ """The file exceptions are recorded in — `.cdec/rules.yaml`."""
1325
+ from code_constraints.waivers import ledger_paths
1326
+
1327
+ return ledger_paths(config_dir)[0]
1328
+
1329
+
1330
+ # ---------- review helpers ----------
1331
+
1332
+ def _collect_issues_cli(
1333
+ config_dir: Path,
1334
+ *,
1335
+ source: Optional[Path] = None,
1336
+ reference: Optional[Path] = None,
1337
+ base_ref: Optional[str] = None,
1338
+ repo: Path = Path("."),
1339
+ ) -> "Collected":
1340
+ """Run every rule and return the keyed issue list, with typer-friendly
1341
+ errors. The baseline-resolution options mirror `cdec check` exactly — the
1342
+ keys only line up if both commands look at the same diff."""
1343
+ from code_constraints.lint.pipeline import PipelineError
1344
+ from code_constraints.waivers import CollectOptions, collect_issues
1345
+ from code_constraints.waivers.store import WaiverFileError
1346
+
1347
+ try:
1348
+ return collect_issues(
1349
+ config_dir,
1350
+ CollectOptions(
1351
+ source=source,
1352
+ reference=reference,
1353
+ base_ref=base_ref,
1354
+ repo=repo,
1355
+ ),
1356
+ )
1357
+ except (ConfigError, PipelineError, WaiverFileError) as exc:
1358
+ typer.echo(str(exc), err=True)
1359
+ raise typer.Exit(code=2) from exc
1360
+
1361
+
1362
+ def _load_waivers_cli(config_dir: Path) -> "WaiverStore":
1363
+ from code_constraints.waivers import load_waivers
1364
+ from code_constraints.waivers.store import WaiverFileError
1365
+
1366
+ try:
1367
+ return load_waivers(config_dir)
1368
+ except WaiverFileError as exc:
1369
+ typer.echo(str(exc), err=True)
1370
+ raise typer.Exit(code=2) from exc
1371
+
1372
+
1373
+ def _read_review_text(file: Path) -> str:
1374
+ if str(file) == "-":
1375
+ import sys
1376
+
1377
+ return sys.stdin.read()
1378
+ if not file.is_file():
1379
+ typer.echo(f"no such file: {file}", err=True)
1380
+ raise typer.Exit(code=2)
1381
+ return file.read_text(encoding="utf-8")
1382
+
1383
+
1384
+ def _write_or_echo(text: str, out: Optional[Path]) -> None:
1385
+ if out is None:
1386
+ typer.echo(text, nl=False)
1387
+ return
1388
+ out.parent.mkdir(parents=True, exist_ok=True)
1389
+ out.write_text(text, encoding="utf-8")
1390
+ typer.echo(f"wrote {out}")
1391
+
1392
+
1393
+ def _issue_to_json(issue: "Issue") -> dict[str, object]:
1394
+ return {
1395
+ "key": issue.key,
1396
+ "engine": issue.engine,
1397
+ "rule": issue.rule,
1398
+ "ruleId": issue.rule_id,
1399
+ "qualifiedName": issue.qualified_name,
1400
+ "detail": issue.detail,
1401
+ "message": issue.message,
1402
+ "severity": issue.severity,
1403
+ "file": issue.file,
1404
+ "line": issue.line,
1405
+ "waived": issue.waived,
1406
+ "waivable": issue.waivable,
1407
+ }
1408
+
1409
+
1410
+ def _report_apply(result: "ApplyResult", ledger_path: Path, *, dry_run: bool) -> None:
1411
+ """Print what an allow/patch/remove did — or would do."""
1412
+ would = "would " if dry_run else ""
1413
+ for issue in result.allowed:
1414
+ detail = f" {issue.detail}" if issue.detail else ""
1415
+ typer.echo(f"{would}allow [{issue.key}] {issue.rule} {issue.qualified_name}{detail}")
1416
+ for waiver in result.removed:
1417
+ typer.echo(f"{would}remove [{waiver.key}] {waiver.rule} {waiver.qualified_name}")
1418
+ for issue in result.already_waived:
1419
+ typer.echo(f"already allowed: [{issue.key}] {issue.qualified_name}")
1420
+ for key in result.not_waived:
1421
+ typer.echo(f"not currently allowed, nothing to remove: {key}", err=True)
1422
+ for key in result.malformed:
1423
+ typer.echo(f"not a valid issue key: {key!r} (expected e.g. V-1A2B3C4D)", err=True)
1424
+ for key in result.unknown:
1425
+ typer.echo(
1426
+ f"unknown key {key}: no issue with that key is being reported. "
1427
+ f"Re-run `cdec check` — the report may be out of date.",
1428
+ err=True,
1429
+ )
1430
+ for _key, message in result.refused:
1431
+ typer.echo(message, err=True)
1432
+ for problem in result.problems:
1433
+ typer.echo(problem, err=True)
1434
+
1435
+ if not result.changed:
1436
+ typer.echo("no changes to the exceptions list.")
1437
+ elif dry_run:
1438
+ typer.echo(f"dry run — {ledger_path} not written.")
1439
+ else:
1440
+ typer.echo(f"wrote {ledger_path}")
1441
+
1442
+
1443
+ def _exit_on_apply_failure(result: "ApplyResult", *, ignore_unknown: bool = False) -> None:
1444
+ failed = bool(result.refused or result.malformed or result.problems)
1445
+ if result.unknown and not ignore_unknown:
1446
+ failed = True
1447
+ if failed:
1448
+ raise typer.Exit(code=1)
1449
+
1450
+
1451
+ def _resolve_reference_inputs(
1452
+ source: Optional[Path],
1453
+ lang: Optional[str],
1454
+ reference: Optional[Path],
1455
+ config_dir: Path,
1456
+ ) -> tuple[Path, str, Path]:
1457
+ """Resolve (source_path, language, reference_path) from explicit args with a
1458
+ `.cdec/` fallback. Explicit args always win; `.cdec/rules.yaml` fills the gaps
1459
+ and `.cdec/reference.xmi` is the conventional reference location."""
1460
+ cfg = None
1461
+ if source is None or lang is None or reference is None:
1462
+ try:
1463
+ cfg = load_project_config(config_dir)
1464
+ except ConfigError:
1465
+ cfg = None
1466
+
1467
+ source_path = source.resolve() if source else (cfg.source if cfg else None)
1468
+ if source_path is None:
1469
+ raise typer.BadParameter(
1470
+ "no source given and none found in .cdec/rules.yaml; pass a SOURCE "
1471
+ "argument or run from a scaffolded project (cdec init)."
1472
+ )
1473
+
1474
+ chosen_lang = lang or (cfg.language if cfg else None) or detect_language(source_path)
1475
+ if chosen_lang is None:
1476
+ raise typer.BadParameter(
1477
+ f"could not determine a language for {source_path}; pass --lang explicitly."
1478
+ )
1479
+ if chosen_lang not in SUPPORTED_LANGUAGES:
1480
+ raise typer.BadParameter(f"unsupported language: {chosen_lang}")
1481
+
1482
+ if reference is not None:
1483
+ ref_path = reference
1484
+ elif cfg is not None:
1485
+ ref_path = cfg.reference_path
1486
+ else:
1487
+ ref_path = config_dir / REFERENCE_FILENAME
1488
+
1489
+ return source_path, chosen_lang, ref_path
1490
+
1491
+
1492
+ def _run_server(host: str, port: int, open_url: Optional[str] = None) -> None:
1493
+ """Run uvicorn (blocking). If `open_url` is set, pop the browser once the
1494
+ server has had a moment to bind."""
1495
+ import uvicorn
1496
+
1497
+ if open_url is not None:
1498
+ import threading
1499
+ import webbrowser
1500
+
1501
+ threading.Timer(1.5, lambda: webbrowser.open(open_url)).start()
1502
+
1503
+ uvicorn.run("code_constraints.web.app:app", host=host, port=port, reload=False)
1504
+
1505
+ def _parse_project(path: Path, lang: str):
1506
+ from code_constraints.lint.pipeline import PipelineError, parse_source
1507
+
1508
+ try:
1509
+ return parse_source(path, lang)
1510
+ except PipelineError as exc:
1511
+ raise typer.BadParameter(str(exc)) from exc
1512
+
1513
+
1514
+ def _checkout_revision(repo: Repo, ref: str, dest: Path) -> Path:
1515
+ """Copy the tree at `ref` into `dest` (avoids touching the working tree)."""
1516
+ from code_constraints.lint.pipeline import checkout_revision
1517
+
1518
+ return checkout_revision(repo, ref, dest)
1519
+
1520
+
1521
+ def _resolve_baseline_and_diff(
1522
+ *,
1523
+ head_proj,
1524
+ cfg_language: str,
1525
+ config_dir: Path,
1526
+ explicit_reference: Optional[Path],
1527
+ explicit_base_ref: Optional[str],
1528
+ repo_path: Path,
1529
+ default_reference: Optional[Path],
1530
+ ):
1531
+ """Returns (project_for_rules, has_diff, baseline_project). When no baseline
1532
+ is available, returns the head project unchanged with has_diff=False and a
1533
+ None baseline (so diff-scope rules will be skipped by the engine).
1534
+
1535
+ Delegates to `lint.pipeline` so `cdec baseline` resolves the baseline the
1536
+ same way — the review keys only line up if both see the same diff."""
1537
+ from code_constraints.lint.pipeline import PipelineError, resolve_baseline
1538
+
1539
+ try:
1540
+ return resolve_baseline(
1541
+ head_proj=head_proj,
1542
+ lang=cfg_language,
1543
+ config_dir=config_dir,
1544
+ explicit_reference=explicit_reference,
1545
+ explicit_base_ref=explicit_base_ref,
1546
+ repo_path=repo_path,
1547
+ default_reference=default_reference,
1548
+ )
1549
+ except PipelineError as exc:
1550
+ typer.echo(str(exc), err=True)
1551
+ raise typer.Exit(code=2) from exc
1552
+
1553
+
1554
+ if __name__ == "__main__":
1555
+ app()