delimit-cli 3.14.16 → 3.14.18

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.
@@ -0,0 +1,246 @@
1
+ """Continuous drift/compliance monitoring — detects API spec changes without governance review.
2
+
3
+ Compares the current spec against the last known baseline and flags:
4
+ - Spec changed without a governance lint run
5
+ - Policy violations that accumulated since last review
6
+ - New endpoints or schemas without documentation
7
+ - Baseline staleness (hasn't been updated in N days)
8
+
9
+ Storage: ~/.delimit/drift/
10
+ """
11
+
12
+ import json
13
+ import time
14
+ import hashlib
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ DRIFT_DIR = Path.home() / ".delimit" / "drift"
19
+ HISTORY_FILE = DRIFT_DIR / "history.jsonl"
20
+ BASELINE_FILE = DRIFT_DIR / "baseline_hash.json"
21
+
22
+
23
+ def _ensure_dir():
24
+ DRIFT_DIR.mkdir(parents=True, exist_ok=True)
25
+
26
+
27
+ def _hash_file(path: str) -> str:
28
+ """SHA256 of a file's contents."""
29
+ try:
30
+ content = Path(path).read_bytes()
31
+ return hashlib.sha256(content).hexdigest()
32
+ except (OSError, FileNotFoundError):
33
+ return ""
34
+
35
+
36
+ def _load_baselines() -> Dict[str, Any]:
37
+ if not BASELINE_FILE.exists():
38
+ return {}
39
+ try:
40
+ return json.loads(BASELINE_FILE.read_text())
41
+ except (json.JSONDecodeError, OSError):
42
+ return {}
43
+
44
+
45
+ def _save_baselines(baselines: Dict[str, Any]):
46
+ _ensure_dir()
47
+ BASELINE_FILE.write_text(json.dumps(baselines, indent=2))
48
+
49
+
50
+ def _append_history(entry: Dict[str, Any]):
51
+ _ensure_dir()
52
+ entry["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
53
+ with open(HISTORY_FILE, "a") as f:
54
+ f.write(json.dumps(entry) + "\n")
55
+
56
+
57
+ def check_drift(
58
+ spec_path: str = "",
59
+ project_path: str = ".",
60
+ staleness_days: int = 7,
61
+ ) -> Dict[str, Any]:
62
+ """Check for API spec drift against the last governance baseline.
63
+
64
+ Returns drift findings and compliance status.
65
+ """
66
+ from pathlib import Path as P
67
+
68
+ project = P(project_path).resolve()
69
+ findings: List[Dict[str, str]] = []
70
+ baselines = _load_baselines()
71
+
72
+ # Auto-detect spec if not provided
73
+ if not spec_path:
74
+ spec_patterns = [
75
+ "openapi.yaml", "openapi.yml", "openapi.json",
76
+ "swagger.yaml", "swagger.yml", "swagger.json",
77
+ "api.yaml", "api.yml", "api.json",
78
+ ]
79
+ for pattern in spec_patterns:
80
+ candidate = project / pattern
81
+ if candidate.exists():
82
+ spec_path = str(candidate)
83
+ break
84
+
85
+ # Check .delimit/baseline.yaml (zero-spec)
86
+ if not spec_path:
87
+ baseline_candidate = project / ".delimit" / "baseline.yaml"
88
+ if baseline_candidate.exists():
89
+ spec_path = str(baseline_candidate)
90
+
91
+ if not spec_path:
92
+ return {
93
+ "status": "no_spec",
94
+ "message": "No OpenAPI spec found. Run `delimit init` to set up governance.",
95
+ "drift_detected": False,
96
+ "findings": [],
97
+ }
98
+
99
+ # Check if spec has changed since last baseline
100
+ current_hash = _hash_file(spec_path)
101
+ spec_key = str(P(spec_path).resolve())
102
+ baseline = baselines.get(spec_key, {})
103
+ last_hash = baseline.get("hash", "")
104
+ last_reviewed = baseline.get("last_reviewed", "")
105
+ last_lint_pass = baseline.get("last_lint_pass", False)
106
+
107
+ drift_detected = False
108
+
109
+ if not last_hash:
110
+ # First run — save baseline
111
+ findings.append({
112
+ "type": "new_baseline",
113
+ "severity": "info",
114
+ "message": "First drift check — baseline recorded.",
115
+ })
116
+ baselines[spec_key] = {
117
+ "hash": current_hash,
118
+ "last_reviewed": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
119
+ "last_lint_pass": True,
120
+ "spec_path": spec_path,
121
+ }
122
+ _save_baselines(baselines)
123
+ elif current_hash != last_hash:
124
+ drift_detected = True
125
+ findings.append({
126
+ "type": "spec_changed",
127
+ "severity": "warning",
128
+ "message": f"API spec changed since last governance review ({last_reviewed or 'unknown'})",
129
+ })
130
+
131
+ # Check staleness
132
+ if last_reviewed:
133
+ try:
134
+ reviewed_ts = time.mktime(time.strptime(last_reviewed, "%Y-%m-%dT%H:%M:%SZ"))
135
+ age_days = (time.time() - reviewed_ts) / 86400
136
+ if age_days > staleness_days:
137
+ drift_detected = True
138
+ findings.append({
139
+ "type": "stale_baseline",
140
+ "severity": "warning",
141
+ "message": f"Baseline is {int(age_days)} days old (threshold: {staleness_days} days)",
142
+ })
143
+ except (ValueError, OverflowError):
144
+ pass
145
+
146
+ # Check for .delimit/policies.yml existence
147
+ policy_file = project / ".delimit" / "policies.yml"
148
+ if not policy_file.exists():
149
+ findings.append({
150
+ "type": "no_policy",
151
+ "severity": "warning",
152
+ "message": "No policy file found. Run `delimit init` to configure governance.",
153
+ })
154
+
155
+ # Check for evidence directory
156
+ evidence_dir = project / ".delimit" / "evidence"
157
+ if not evidence_dir.exists():
158
+ findings.append({
159
+ "type": "no_evidence",
160
+ "severity": "info",
161
+ "message": "No evidence directory. First governance run will create it.",
162
+ })
163
+
164
+ # Record drift check in history
165
+ _append_history({
166
+ "action": "drift_check",
167
+ "spec_path": spec_path,
168
+ "drift_detected": drift_detected,
169
+ "findings_count": len(findings),
170
+ "current_hash": current_hash[:16],
171
+ })
172
+
173
+ return {
174
+ "status": "drift" if drift_detected else "clean",
175
+ "drift_detected": drift_detected,
176
+ "spec_path": spec_path,
177
+ "findings": findings,
178
+ "baseline": {
179
+ "hash": last_hash[:16] if last_hash else None,
180
+ "last_reviewed": last_reviewed,
181
+ "current_hash": current_hash[:16],
182
+ },
183
+ "recommendation": (
184
+ "Run `delimit lint` to review spec changes and update the governance baseline."
185
+ if drift_detected else
186
+ "No drift detected. Governance baseline is current."
187
+ ),
188
+ }
189
+
190
+
191
+ def update_baseline(spec_path: str, lint_passed: bool = True) -> Dict[str, Any]:
192
+ """Update the drift baseline after a successful governance review."""
193
+ from pathlib import Path as P
194
+
195
+ if not spec_path:
196
+ return {"error": "spec_path is required"}
197
+
198
+ current_hash = _hash_file(spec_path)
199
+ if not current_hash:
200
+ return {"error": f"Cannot read spec at {spec_path}"}
201
+
202
+ spec_key = str(P(spec_path).resolve())
203
+ baselines = _load_baselines()
204
+
205
+ baselines[spec_key] = {
206
+ "hash": current_hash,
207
+ "last_reviewed": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
208
+ "last_lint_pass": lint_passed,
209
+ "spec_path": spec_path,
210
+ }
211
+ _save_baselines(baselines)
212
+
213
+ _append_history({
214
+ "action": "baseline_update",
215
+ "spec_path": spec_path,
216
+ "hash": current_hash[:16],
217
+ "lint_passed": lint_passed,
218
+ })
219
+
220
+ return {
221
+ "status": "updated",
222
+ "spec_path": spec_path,
223
+ "hash": current_hash[:16],
224
+ "message": "Drift baseline updated.",
225
+ }
226
+
227
+
228
+ def get_drift_history(limit: int = 20) -> Dict[str, Any]:
229
+ """Return recent drift check history."""
230
+ entries: List[Dict] = []
231
+ if HISTORY_FILE.exists():
232
+ try:
233
+ lines = HISTORY_FILE.read_text().strip().split("\n")
234
+ for line in lines[-limit:]:
235
+ try:
236
+ entries.append(json.loads(line))
237
+ except json.JSONDecodeError:
238
+ pass
239
+ except OSError:
240
+ pass
241
+
242
+ return {
243
+ "status": "ok",
244
+ "entries": entries,
245
+ "total": len(entries),
246
+ }