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,395 @@
1
+ """The implementation-freeze engine (Engine C).
2
+
3
+ Reached through the `implementation-locks` rule type in `.cdec/rules.yaml`, so
4
+ freezing a body is configured, reported and gated by the same `cdec check` as
5
+ every other law. The engine itself stays an independent package with its own
6
+ result types; the rule is a thin adapter.
7
+
8
+ Two operations, both driven off the same collected fingerprints:
9
+
10
+ * `check_locks` — verify every ledger entry still matches the source.
11
+ * `update_locks` — (re-)record digests; the lead-gated re-baseline path.
12
+
13
+ Locks are declared two ways, and both are honoured:
14
+
15
+ * a `@locked` / `[Locked]` tag on the element — the primary, in-code route;
16
+ * `targets:` globs on the `implementation-locks` rule in `.cdec/rules.yaml` —
17
+ for freezing code that isn't practical to decorate, e.g. a whole test
18
+ package.
19
+
20
+ The engine never consults the diff, the reference XMI, or the baseline: a lock
21
+ is an absolute statement about the current source, not a drift signal.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Iterable, Sequence
30
+
31
+ from code_constraints.lock.model import (
32
+ LOCK_RULE,
33
+ LockEntry,
34
+ LockReport,
35
+ LockTarget,
36
+ LockViolation,
37
+ match_any_glob,
38
+ )
39
+ from code_constraints.lock.store import now_stamp
40
+
41
+ BYPASS_ENV = "CDEC_LOCK_BYPASS"
42
+ BYPASS_REASON_ENV = "CDEC_LOCK_BYPASS_REASON"
43
+
44
+
45
+ class UnsupportedLockLanguage(ValueError):
46
+ """Raised for a language with no fingerprinter."""
47
+
48
+
49
+ @dataclass
50
+ class LockOptions:
51
+ include_docstrings: bool = False
52
+ patterns: list[str] = field(default_factory=list)
53
+
54
+
55
+ @dataclass
56
+ class UpdateResult:
57
+ added: list[LockEntry] = field(default_factory=list)
58
+ updated: list[LockEntry] = field(default_factory=list)
59
+ removed: list[LockEntry] = field(default_factory=list)
60
+ unchanged: int = 0
61
+ # Entries whose digest drifted but that `--force` was not given for.
62
+ blocked: list[LockViolation] = field(default_factory=list)
63
+ # Entries still in the ledger whose element is gone or has lost its tag, and
64
+ # which were left in place because `--force` was not given. Reporting these
65
+ # matters: without them an unforced run over a tampered-with tree would
66
+ # cheerfully print "nothing to do" while the lock is being violated.
67
+ stale: list[LockEntry] = field(default_factory=list)
68
+
69
+ @property
70
+ def changed(self) -> bool:
71
+ return bool(self.added or self.updated or self.removed)
72
+
73
+ @property
74
+ def clean(self) -> bool:
75
+ return not self.blocked and not self.stale
76
+
77
+
78
+ def collect_targets(
79
+ root: str | Path, lang: str, options: LockOptions | None = None
80
+ ) -> list[LockTarget]:
81
+ """Fingerprint every lockable element under `root`."""
82
+ opts = options or LockOptions()
83
+ if lang == "python":
84
+ from code_constraints.python.fingerprint import collect_lockables
85
+ elif lang == "csharp":
86
+ from code_constraints.csharp.fingerprint import collect_lockables
87
+ elif lang == "odin":
88
+ from code_constraints.odin.fingerprint import collect_lockables
89
+ elif lang == "lua":
90
+ from code_constraints.lua.fingerprint import collect_lockables
91
+ elif lang == "julia":
92
+ from code_constraints.julia.fingerprint import collect_lockables
93
+ else:
94
+ raise UnsupportedLockLanguage(
95
+ f"implementation locks support python, csharp, odin, lua and julia; "
96
+ f"got {lang!r}. "
97
+ f"Implementation freezing needs an AST fingerprinter for the language."
98
+ )
99
+ return collect_lockables(root, include_docstrings=opts.include_docstrings)
100
+
101
+
102
+ def is_locked_target(target: LockTarget, patterns: Sequence[str]) -> bool:
103
+ """A target is under lock when it carries the tag or matches a config glob."""
104
+ return target.declared or match_any_glob(target.target, patterns)
105
+
106
+
107
+ def check_locks(
108
+ root: str | Path,
109
+ lang: str,
110
+ entries: dict[str, LockEntry],
111
+ options: LockOptions | None = None,
112
+ *,
113
+ bypass: bool = False,
114
+ bypass_reason: str = "",
115
+ ) -> LockReport:
116
+ """Verify the ledger against the current source.
117
+
118
+ Five failure modes, all of them real ways a frozen implementation stops
119
+ being frozen:
120
+
121
+ * `changed` — the digest moved: the body was edited.
122
+ * `missing` — tagged `@locked` but never baselined, so nothing is
123
+ actually being verified.
124
+ * `removed` — the element is gone (deleting it is a mutation too).
125
+ * `unlocked` — the element survives but the tag was deleted; without
126
+ this check, escaping a lock is a one-line edit.
127
+ * `algo-mismatch` — the digest was produced by a different algorithm
128
+ version, so the comparison is meaningless. Reported
129
+ separately so an upgrade never looks like tampering.
130
+ """
131
+ opts = options or LockOptions()
132
+ env_bypass, env_reason = _env_bypass()
133
+ bypass = bypass or env_bypass
134
+ bypass_reason = bypass_reason or env_reason
135
+
136
+ targets = collect_targets(root, lang, opts)
137
+ by_name = {t.target: t for t in targets}
138
+ violations: list[LockViolation] = []
139
+
140
+ for name, entry in sorted(entries.items()):
141
+ target = by_name.get(name)
142
+ if target is None:
143
+ violations.append(
144
+ LockViolation(
145
+ kind="removed",
146
+ target=name,
147
+ file=entry.file,
148
+ message=(
149
+ f"'{name}' is locked in the ledger but no longer exists in the "
150
+ f"source. Deleting or renaming a frozen element is a change: "
151
+ f"restore it, or have a lead drop the lock with "
152
+ f"`cdec check --automatic-exceptions locks --force`."
153
+ + (f"\nLock reason: {entry.reason}" if entry.reason else "")
154
+ ),
155
+ reason=entry.reason,
156
+ owner=entry.locked_by,
157
+ )
158
+ )
159
+ continue
160
+
161
+ if entry.algo and target.algo != entry.algo:
162
+ violations.append(
163
+ LockViolation(
164
+ kind="algo-mismatch",
165
+ target=name,
166
+ file=target.file,
167
+ line=target.line,
168
+ expected=entry.algo,
169
+ actual=target.algo,
170
+ message=(
171
+ f"'{name}' was baselined with digest algorithm "
172
+ f"'{entry.algo}' but this code-constraints computes "
173
+ f"'{target.algo}'. The digests are not comparable — "
174
+ f"re-baseline with "
175
+ f"`cdec check --automatic-exceptions locks --force` after "
176
+ f"confirming the implementation is unchanged."
177
+ ),
178
+ )
179
+ )
180
+ continue
181
+
182
+ if target.digest != entry.digest:
183
+ violations.append(
184
+ LockViolation(
185
+ kind="changed",
186
+ target=name,
187
+ file=target.file,
188
+ line=target.line,
189
+ expected=entry.digest,
190
+ actual=target.digest,
191
+ message=_changed_message(name, entry, target),
192
+ reason=entry.reason or target.reason,
193
+ owner=entry.locked_by or target.owner,
194
+ )
195
+ )
196
+ continue
197
+
198
+ if not entry.via_pattern and not is_locked_target(target, opts.patterns):
199
+ violations.append(
200
+ LockViolation(
201
+ kind="unlocked",
202
+ target=name,
203
+ file=target.file,
204
+ line=target.line,
205
+ message=(
206
+ f"'{name}' is recorded in the lock ledger but its @{LOCK_RULE} "
207
+ f"tag is gone. Removing the tag does not remove the lock — "
208
+ f"restore it, or have a lead run "
209
+ f"`cdec check --automatic-exceptions locks --force`."
210
+ ),
211
+ reason=entry.reason,
212
+ owner=entry.locked_by,
213
+ )
214
+ )
215
+
216
+ declared = 0
217
+ for target in targets:
218
+ if not is_locked_target(target, opts.patterns):
219
+ continue
220
+ declared += 1
221
+ if target.target in entries:
222
+ continue
223
+ violations.append(
224
+ LockViolation(
225
+ kind="missing",
226
+ target=target.target,
227
+ file=target.file,
228
+ line=target.line,
229
+ actual=target.digest,
230
+ message=(
231
+ f"'{target.target}' is tagged @{LOCK_RULE} but has no baseline "
232
+ f"digest, so nothing is being verified. Record it with "
233
+ f"`cdec check --automatic-exceptions locks`."
234
+ ),
235
+ reason=target.reason,
236
+ owner=target.owner,
237
+ )
238
+ )
239
+
240
+ return LockReport(
241
+ violations=violations,
242
+ checked=len(entries),
243
+ declared=declared,
244
+ bypassed=bypass,
245
+ bypass_reason=bypass_reason,
246
+ )
247
+
248
+
249
+ def update_locks(
250
+ root: str | Path,
251
+ lang: str,
252
+ entries: dict[str, LockEntry],
253
+ options: LockOptions | None = None,
254
+ *,
255
+ only: Sequence[str] = (),
256
+ force: bool = False,
257
+ prune: bool = True,
258
+ reason: str = "",
259
+ owner: str = "",
260
+ ) -> tuple[dict[str, LockEntry], UpdateResult]:
261
+ """Recompute digests and return the new ledger plus a summary of changes.
262
+
263
+ Adding a lock is cheap; *re-baselining a drifted one is the privileged
264
+ operation*, so an existing entry whose digest moved is only rewritten with
265
+ `force=True`. That keeps `cdec check --automatic-exceptions locks` safe to
266
+ run by anyone — without `--force` it can never erase evidence of a change —
267
+ while the `--force` run shows up as a reviewable diff on the `locks:`
268
+ section of `.cdec/rules.yaml`.
269
+
270
+ `only` restricts the operation to matching targets (globs). `prune` drops
271
+ ledger entries whose element no longer exists or is no longer locked.
272
+ """
273
+ opts = options or LockOptions()
274
+ targets = collect_targets(root, lang, opts)
275
+ by_name = {t.target: t for t in targets}
276
+ result = UpdateResult()
277
+ out = dict(entries)
278
+ stamp = now_stamp()
279
+ who = owner or _default_owner()
280
+
281
+ for target in targets:
282
+ if not is_locked_target(target, opts.patterns):
283
+ continue
284
+ if only and not match_any_glob(target.target, only):
285
+ continue
286
+ existing = out.get(target.target)
287
+ via_pattern = not target.declared
288
+ if existing is None:
289
+ entry = _entry_for(target, stamp, who, reason, via_pattern)
290
+ out[target.target] = entry
291
+ result.added.append(entry)
292
+ elif existing.digest == target.digest and existing.algo == target.algo:
293
+ result.unchanged += 1
294
+ elif force:
295
+ entry = _entry_for(
296
+ target, stamp, who, reason or existing.reason, via_pattern
297
+ )
298
+ out[target.target] = entry
299
+ result.updated.append(entry)
300
+ else:
301
+ result.blocked.append(
302
+ LockViolation(
303
+ kind="changed",
304
+ target=target.target,
305
+ file=target.file,
306
+ line=target.line,
307
+ expected=existing.digest,
308
+ actual=target.digest,
309
+ message=(
310
+ f"'{target.target}' has drifted from its baseline. Re-baselining "
311
+ f"a frozen implementation requires --force (and a lead's "
312
+ f"approval on the `locks:` diff in .cdec/rules.yaml)."
313
+ ),
314
+ reason=existing.reason,
315
+ owner=existing.locked_by,
316
+ )
317
+ )
318
+
319
+ for name, entry in list(out.items()):
320
+ if only and not match_any_glob(name, only):
321
+ continue
322
+ target = by_name.get(name)
323
+ if target is not None and is_locked_target(target, opts.patterns):
324
+ continue
325
+ # The element is gone or its tag was deleted. Dropping the ledger entry
326
+ # is privileged: an unforced run leaves it in place so `cdec check` keeps
327
+ # reporting it, rather than letting anyone unlock by deleting a decorator
328
+ # and re-running the baselining command.
329
+ if prune and force:
330
+ del out[name]
331
+ result.removed.append(entry)
332
+ else:
333
+ result.stale.append(entry)
334
+
335
+ return out, result
336
+
337
+
338
+ def resolve_entries_for_removal(
339
+ entries: dict[str, LockEntry], patterns: Sequence[str]
340
+ ) -> list[LockEntry]:
341
+ return [e for name, e in sorted(entries.items()) if match_any_glob(name, patterns)]
342
+
343
+
344
+ # ---------- internals ----------
345
+
346
+ def _entry_for(
347
+ target: LockTarget, stamp: str, who: str, reason: str, via_pattern: bool
348
+ ) -> LockEntry:
349
+ return LockEntry(
350
+ target=target.target,
351
+ kind=target.kind,
352
+ digest=target.digest,
353
+ algo=target.algo,
354
+ file=target.file,
355
+ locked_at=stamp,
356
+ locked_by=who,
357
+ reason=reason or target.reason,
358
+ via_pattern=via_pattern,
359
+ )
360
+
361
+
362
+ def _changed_message(name: str, entry: LockEntry, target: LockTarget) -> str:
363
+ lines = [
364
+ f"'{name}' is a frozen {entry.kind or target.kind} and its implementation "
365
+ f"changed.",
366
+ ]
367
+ if entry.reason:
368
+ lines.append(f"Lock reason: {entry.reason}")
369
+ if entry.locked_by:
370
+ lines.append(f"Locked by: {entry.locked_by}" + (f" on {entry.locked_at}" if entry.locked_at else ""))
371
+ lines.append(
372
+ "Revert the change, or ask a lead to approve a re-baseline with "
373
+ "`cdec check --automatic-exceptions locks --force`."
374
+ )
375
+ return "\n".join(lines)
376
+
377
+
378
+ def _env_bypass() -> tuple[bool, str]:
379
+ raw = os.environ.get(BYPASS_ENV, "").strip().lower()
380
+ enabled = raw in ("1", "true", "yes", "on")
381
+ return enabled, os.environ.get(BYPASS_REASON_ENV, "").strip() if enabled else ""
382
+
383
+
384
+ def _default_owner() -> str:
385
+ for var in ("CDEC_LOCK_OWNER", "GIT_AUTHOR_NAME", "USERNAME", "USER"):
386
+ value = os.environ.get(var, "").strip()
387
+ if value:
388
+ return value
389
+ return ""
390
+
391
+
392
+ def iter_locked(targets: Iterable[LockTarget], patterns: Sequence[str]):
393
+ for target in targets:
394
+ if is_locked_target(target, patterns):
395
+ yield target
@@ -0,0 +1,235 @@
1
+ """Result types for the implementation-freeze engine (Engine C).
2
+
3
+ Deliberately separate from `code_constraints.lint.Violation` and
4
+ `code_constraints.enforce.Finding`: the
5
+ three engines are decoupled by design and share only the rule *catalog*.
6
+
7
+ * Engine A (configured rules) — did the architectural *intent* drift?
8
+ * Engine B (tag conformance) — does the code *obey* the tags right now?
9
+ * Engine C (locks) — did a frozen implementation *change at all*?
10
+
11
+ All three run inside `cdec check`, as rule types in `.cdec/rules.yaml`; they
12
+ share only the rule catalog. Engine C is the only one that cares about the exact
13
+ contents of a body. It compares an AST-derived digest against the digest
14
+ recorded in the `locks:` section of `rules.yaml`, so reformatting, comment edits
15
+ and moving the element around a file never trip a lock, while any semantic edit
16
+ does.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import fnmatch
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Iterable, Literal
24
+
25
+ from code_constraints.core.keys import make_key
26
+
27
+ # Catalog id of the tag that declares a lock. Never hard-code this elsewhere —
28
+ # import it (or look it up via `code_constraints.core.rules.by_id`).
29
+ LOCK_RULE = "locked"
30
+
31
+ TargetKind = Literal["class", "method", "function"]
32
+ ViolationKind = Literal["changed", "missing", "removed", "unlocked", "algo-mismatch"]
33
+
34
+
35
+ @dataclass
36
+ class LockTarget:
37
+ """A lockable element discovered in the current source tree.
38
+
39
+ Produced by the per-language fingerprinters (`code_constraints/{python,csharp}
40
+ .fingerprint`). `digest` is over the *normalised* AST: position-independent,
41
+ comment-free, and (by default) docstring-free.
42
+
43
+ Overloads / same-named siblings in one scope collapse into a single target
44
+ whose digest covers the whole group, so adding an overload to a locked
45
+ method is itself a lock violation and no ordinal disambiguator is needed.
46
+ """
47
+
48
+ target: str # dotted identity, e.g. "orders.Receipt.formatted"
49
+ kind: TargetKind
50
+ digest: str # hex sha256 of the canonical AST serialisation
51
+ algo: str # digest algorithm id, e.g. "py-ast/1"
52
+ file: str = ""
53
+ line: int = 0
54
+ declared: bool = False # carries an explicit @locked / [Locked] tag
55
+ params: dict[str, str] = field(default_factory=dict) # tag kwargs (reason/owner)
56
+
57
+ @property
58
+ def reason(self) -> str:
59
+ return self._param("reason")
60
+
61
+ @property
62
+ def owner(self) -> str:
63
+ return self._param("owner")
64
+
65
+ def _param(self, name: str) -> str:
66
+ """Tag params are case-insensitive: Python writes `reason=`, C# writes
67
+ `Reason =`, and both land in the same catalog param."""
68
+ for key, value in self.params.items():
69
+ if key.lower() == name:
70
+ return _unquote(value)
71
+ return ""
72
+
73
+
74
+ @dataclass
75
+ class LockEntry:
76
+ """One recorded lock in the `locks:` section of `.cdec/rules.yaml`."""
77
+
78
+ target: str
79
+ kind: str
80
+ digest: str
81
+ algo: str
82
+ file: str = ""
83
+ locked_at: str = ""
84
+ locked_by: str = ""
85
+ reason: str = ""
86
+ # True when the lock came from a rule `targets:` glob rather than a tag.
87
+ # Glob-locked entries are exempt from the "tag was removed" check.
88
+ via_pattern: bool = False
89
+
90
+
91
+ @dataclass
92
+ class LockViolation:
93
+ kind: ViolationKind
94
+ target: str
95
+ message: str
96
+ file: str = ""
97
+ line: int = 0
98
+ expected: str = ""
99
+ actual: str = ""
100
+ reason: str = ""
101
+ owner: str = ""
102
+
103
+ def fingerprint(self) -> tuple[str, str]:
104
+ return (self.kind, self.target)
105
+
106
+ def key(self) -> str:
107
+ """Stable review key (`L-…`) — see `code_constraints.core.keys`.
108
+
109
+ Locks are the one thing `cdec exceptions` will not accept: a changed
110
+ implementation is approved with
111
+ `cdec check --automatic-exceptions locks --force`, a privileged,
112
+ reviewable act. The key exists anyway so reports are uniform and an
113
+ agent can name the lock it means.
114
+ """
115
+ return make_key("lock", self.kind, self.target, "")
116
+
117
+
118
+ @dataclass
119
+ class LockReport:
120
+ violations: list[LockViolation] = field(default_factory=list)
121
+ checked: int = 0 # number of lockfile entries verified
122
+ declared: int = 0 # number of @locked elements found in source
123
+ bypassed: bool = False
124
+ bypass_reason: str = ""
125
+
126
+ @property
127
+ def ok(self) -> bool:
128
+ """True when nothing blocks the build. A bypassed run is never a
129
+ failure — but `bypassed` stays in the report so CI can reject it."""
130
+ return self.bypassed or not self.violations
131
+
132
+
133
+ # ---------- rendering ----------
134
+
135
+ _KIND_HEADLINE: dict[str, str] = {
136
+ "changed": "frozen implementation changed",
137
+ "missing": "declared @locked but not baselined",
138
+ "removed": "frozen element no longer exists",
139
+ "unlocked": "@locked tag was removed",
140
+ "algo-mismatch": "digest algorithm changed",
141
+ }
142
+
143
+
144
+ def format_report(report: LockReport) -> str:
145
+ """Standalone rendering of a lock report.
146
+
147
+ `cdec check` renders lock violations through the unified report instead;
148
+ this stays for library callers and for the MCP tools that surface the lock
149
+ ledger on its own.
150
+ """
151
+ if report.bypassed:
152
+ head = [
153
+ "!" * 72,
154
+ "LOCKS BYPASSED — frozen implementations were NOT verified.",
155
+ ]
156
+ if report.bypass_reason:
157
+ head.append(f" reason: {report.bypass_reason}")
158
+ head.append(
159
+ f" {len(report.violations)} lock violation(s) suppressed."
160
+ )
161
+ head.append("!" * 72)
162
+ return "\n".join(head) + "\n"
163
+ if not report.violations:
164
+ return (
165
+ f"{report.checked} locked element(s) verified, no changes.\n"
166
+ )
167
+
168
+ by_kind: dict[str, list[LockViolation]] = {}
169
+ for v in report.violations:
170
+ by_kind.setdefault(v.kind, []).append(v)
171
+
172
+ lines = [f"{len(report.violations)} lock violation(s):"]
173
+ for kind in sorted(by_kind):
174
+ lines.append(f"[{kind}] {_KIND_HEADLINE.get(kind, '')}")
175
+ for v in sorted(by_kind[kind], key=lambda x: x.target):
176
+ loc = f" — {v.file}:{v.line}" if v.file else ""
177
+ lines.append(f" - [{v.key()}] {v.target}{loc}")
178
+ for cont in (v.message or "").splitlines():
179
+ lines.append(f" {cont}" if cont else "")
180
+ lines.append("")
181
+ lines.append(
182
+ "A locked implementation may only change with a lead's approval:\n"
183
+ " cdec check --automatic-exceptions locks --force\n"
184
+ "To ship without re-baselining (audited, discouraged):\n"
185
+ " cdec check --bypass-locks --bypass-reason \"<why>\""
186
+ )
187
+ return "\n".join(lines) + "\n"
188
+
189
+
190
+ def report_to_json(report: LockReport) -> dict[str, Any]:
191
+ return {
192
+ "violations": [
193
+ {
194
+ "key": v.key(),
195
+ "kind": v.kind,
196
+ "target": v.target,
197
+ "message": v.message,
198
+ "file": v.file,
199
+ "line": v.line,
200
+ "expected": v.expected,
201
+ "actual": v.actual,
202
+ "reason": v.reason,
203
+ "owner": v.owner,
204
+ }
205
+ for v in report.violations
206
+ ],
207
+ "summary": {
208
+ "checked": report.checked,
209
+ "declared": report.declared,
210
+ "violations": len(report.violations),
211
+ "bypassed": report.bypassed,
212
+ "bypass_reason": report.bypass_reason,
213
+ },
214
+ }
215
+
216
+
217
+ # ---------- helpers ----------
218
+
219
+ def match_any_glob(name: str, patterns: Iterable[str]) -> bool:
220
+ """Glob match with `.` treated as a normal character, so `tests.**` matches
221
+ every descendant of `tests`. Mirrors `code_constraints.lint.rules.base.match_any_glob`;
222
+ duplicated rather than imported to keep the engines decoupled."""
223
+ for p in patterns:
224
+ if fnmatch.fnmatchcase(name, p.replace("**", "*")):
225
+ return True
226
+ return False
227
+
228
+
229
+ def _unquote(raw: str) -> str:
230
+ """Tag kwargs hold raw source text, so a Python `reason="x"` arrives as
231
+ `'"x"'` and a C# one as `"\"x\""`. Strip one layer of matching quotes."""
232
+ text = raw.strip()
233
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'":
234
+ return text[1:-1]
235
+ return text