code-constraints 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_constraints/__init__.py +1 -0
- code_constraints/cli/__init__.py +0 -0
- code_constraints/cli/__main__.py +1555 -0
- code_constraints/cli/_assets/agents/cdec-architect.md +468 -0
- code_constraints/cli/_assets/agents/oop-refactor-architect.md +317 -0
- code_constraints/cli/_assets/shims/csharp/CodeConstraintsRules.cs +94 -0
- code_constraints/cli/_assets/shims/julia/CdecRules.jl +129 -0
- code_constraints/cli/_assets/shims/lua/cdec_rules.lua +92 -0
- code_constraints/cli/_assets/shims/odin/cdec_rules.odin +67 -0
- code_constraints/cli/_assets/shims/python/cdec_rules.py +94 -0
- code_constraints/cli/_assets/skills/cdec-architecture-loop/SKILL.md +152 -0
- code_constraints/cli/depstamp.py +118 -0
- code_constraints/cli/detect.py +77 -0
- code_constraints/cli/interactive.py +304 -0
- code_constraints/cli/scaffold.py +602 -0
- code_constraints/cli/update.py +157 -0
- code_constraints/core/__init__.py +41 -0
- code_constraints/core/annotations.py +217 -0
- code_constraints/core/associations.py +134 -0
- code_constraints/core/diff.py +302 -0
- code_constraints/core/editor_io.py +280 -0
- code_constraints/core/graph_model.py +681 -0
- code_constraints/core/keys.py +105 -0
- code_constraints/core/model.py +294 -0
- code_constraints/core/model_io.py +65 -0
- code_constraints/core/receivers.py +34 -0
- code_constraints/core/rules.py +177 -0
- code_constraints/core/rulesdoc.py +208 -0
- code_constraints/core/tags.py +114 -0
- code_constraints/core/ts_fingerprint.py +88 -0
- code_constraints/core/xmi_reader.py +358 -0
- code_constraints/core/xmi_writer.py +373 -0
- code_constraints/csharp/__init__.py +3 -0
- code_constraints/csharp/activity.py +250 -0
- code_constraints/csharp/conformance.py +331 -0
- code_constraints/csharp/fingerprint.py +274 -0
- code_constraints/csharp/parser.py +436 -0
- code_constraints/csharp/rules_extract.py +78 -0
- code_constraints/csharp/sequence.py +295 -0
- code_constraints/enforce/__init__.py +15 -0
- code_constraints/enforce/engine.py +122 -0
- code_constraints/enforce/model.py +74 -0
- code_constraints/julia/__init__.py +5 -0
- code_constraints/julia/conformance.py +282 -0
- code_constraints/julia/fingerprint.py +226 -0
- code_constraints/julia/parser.py +523 -0
- code_constraints/julia/rules_extract.py +216 -0
- code_constraints/lint/__init__.py +10 -0
- code_constraints/lint/baseline.py +96 -0
- code_constraints/lint/config.py +239 -0
- code_constraints/lint/engine.py +179 -0
- code_constraints/lint/pipeline.py +108 -0
- code_constraints/lint/report.py +151 -0
- code_constraints/lint/rules/__init__.py +50 -0
- code_constraints/lint/rules/base.py +200 -0
- code_constraints/lint/rules/cyclic_package_dependencies.py +69 -0
- code_constraints/lint/rules/dangling_classes.py +98 -0
- code_constraints/lint/rules/forbidden_package_references.py +47 -0
- code_constraints/lint/rules/forbidden_references.py +48 -0
- code_constraints/lint/rules/frozen_members.py +67 -0
- code_constraints/lint/rules/frozen_rules.py +105 -0
- code_constraints/lint/rules/implementation_locks.py +156 -0
- code_constraints/lint/rules/layer_dependencies.py +92 -0
- code_constraints/lint/rules/max_class_fanout.py +41 -0
- code_constraints/lint/rules/no_new_classes.py +27 -0
- code_constraints/lint/rules/no_removed_classes.py +27 -0
- code_constraints/lint/rules/reference_architecture.py +111 -0
- code_constraints/lint/rules/subclass_naming.py +71 -0
- code_constraints/lint/rules/tag_conformance.py +76 -0
- code_constraints/lock/__init__.py +73 -0
- code_constraints/lock/engine.py +395 -0
- code_constraints/lock/model.py +235 -0
- code_constraints/lock/store.py +144 -0
- code_constraints/lua/__init__.py +5 -0
- code_constraints/lua/conformance.py +239 -0
- code_constraints/lua/fingerprint.py +252 -0
- code_constraints/lua/parser.py +500 -0
- code_constraints/lua/rules_extract.py +55 -0
- code_constraints/mcp/__init__.py +20 -0
- code_constraints/mcp/__main__.py +73 -0
- code_constraints/mcp/server.py +1203 -0
- code_constraints/odin/__init__.py +5 -0
- code_constraints/odin/conformance.py +244 -0
- code_constraints/odin/fingerprint.py +159 -0
- code_constraints/odin/parser.py +471 -0
- code_constraints/odin/rules_extract.py +38 -0
- code_constraints/python/__init__.py +3 -0
- code_constraints/python/activity.py +278 -0
- code_constraints/python/conformance.py +249 -0
- code_constraints/python/fingerprint.py +231 -0
- code_constraints/python/parser.py +330 -0
- code_constraints/python/rules_extract.py +83 -0
- code_constraints/python/sequence.py +257 -0
- code_constraints/reference/__init__.py +15 -0
- code_constraints/reference/compare.py +356 -0
- code_constraints/reference/report.py +38 -0
- code_constraints/svelte/__init__.py +3 -0
- code_constraints/svelte/parser.py +523 -0
- code_constraints/typescript/__init__.py +3 -0
- code_constraints/typescript/parser.py +590 -0
- code_constraints/waivers/__init__.py +89 -0
- code_constraints/waivers/collect.py +167 -0
- code_constraints/waivers/model.py +90 -0
- code_constraints/waivers/ops.py +150 -0
- code_constraints/waivers/review.py +156 -0
- code_constraints/waivers/store.py +300 -0
- code_constraints/web/__init__.py +0 -0
- code_constraints/web/_static/assets/index-3ivBsYY4.css +1 -0
- code_constraints/web/_static/assets/index-BTzTqGFp.js +9 -0
- code_constraints/web/_static/index.html +13 -0
- code_constraints/web/app.py +1076 -0
- code_constraints-0.1.0.dist-info/METADATA +663 -0
- code_constraints-0.1.0.dist-info/RECORD +116 -0
- code_constraints-0.1.0.dist-info/WHEEL +4 -0
- code_constraints-0.1.0.dist-info/entry_points.txt +3 -0
- code_constraints-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|