loki-mode 5.43.0 → 5.46.0

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,368 @@
1
+ #!/usr/bin/env python3
2
+ """PRD Checklist Verification Engine (v5.45.0)
3
+
4
+ Reads .loki/checklist/checklist.json, runs each verification check with
5
+ subprocess timeouts, and writes results atomically.
6
+
7
+ Check types:
8
+ - file_exists: os.path.exists(path)
9
+ - file_contains: file exists AND matches regex
10
+ - tests_pass: subprocess with 30s timeout, check exit code
11
+ - command: arbitrary shell command, 30s timeout, check exit code
12
+ - grep_codebase: grep -r for pattern in project
13
+ - http_check: HTTP GET to app URL + path, check status code
14
+
15
+ Timeout = item stays 'pending' (not 'failed') to prevent false failures.
16
+ Atomic writes: temp file + os.replace() to never produce partial JSON.
17
+
18
+ Usage:
19
+ python3 checklist-verify.py [--checklist PATH] [--timeout SECS]
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ import tempfile
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+
32
+
33
+ # Allowed characters in check paths and patterns (security: prevent injection)
34
+ _SAFE_PATH_RE = re.compile(r'^[a-zA-Z0-9_\-./\*\[\]{}?]+$')
35
+ _SAFE_PATTERN_RE = re.compile(r'^[a-zA-Z0-9_\-./\*\[\]{}?|\\()+^$\s]+$')
36
+
37
+
38
+ def _validate_path(path: str, project_dir: str) -> str:
39
+ """Validate and resolve a path, preventing traversal outside project."""
40
+ if not path or not _SAFE_PATH_RE.match(path):
41
+ raise ValueError(f"Invalid path characters: {path!r}")
42
+ resolved = os.path.realpath(os.path.join(project_dir, path))
43
+ project_real = os.path.realpath(project_dir)
44
+ if not resolved.startswith(project_real + os.sep) and resolved != project_real:
45
+ raise ValueError(f"Path traversal blocked: {path!r}")
46
+ return resolved
47
+
48
+
49
+ def run_check(check: dict, project_dir: str, timeout: int) -> dict:
50
+ """Run a single verification check and return updated check dict."""
51
+ check_type = check.get("type", "")
52
+ result = dict(check)
53
+
54
+ try:
55
+ if check_type == "file_exists":
56
+ path = check.get("path", "")
57
+ full_path = _validate_path(path, project_dir)
58
+ result["passed"] = os.path.exists(full_path)
59
+
60
+ elif check_type == "file_contains":
61
+ path = check.get("path", "")
62
+ pattern = check.get("pattern", "")
63
+ if pattern and not _SAFE_PATTERN_RE.match(pattern):
64
+ result["passed"] = None
65
+ result["output"] = f"Unsafe pattern rejected: {pattern!r}"
66
+ return result
67
+ full_path = _validate_path(path, project_dir)
68
+ if os.path.isfile(full_path):
69
+ try:
70
+ content = Path(full_path).read_text(errors="replace")
71
+ result["passed"] = bool(re.search(pattern, content))
72
+ except re.error as e:
73
+ result["passed"] = False
74
+ result["output"] = f"Invalid regex: {e}"
75
+ except Exception:
76
+ result["passed"] = False
77
+ else:
78
+ result["passed"] = False
79
+
80
+ elif check_type == "tests_pass":
81
+ pattern = check.get("pattern", "")
82
+ # Sanitize pattern - only allow safe glob/path characters
83
+ if pattern and not _SAFE_PATTERN_RE.match(pattern):
84
+ result["passed"] = None
85
+ result["output"] = f"Unsafe pattern rejected: {pattern!r}"
86
+ elif pattern:
87
+ # Use list form (shell=False) to prevent injection
88
+ if os.path.isfile(os.path.join(project_dir, "package.json")):
89
+ cmd = ["npx", "jest", "--testPathPattern", pattern, "--passWithNoTests"]
90
+ else:
91
+ cmd = ["python3", "-m", "pytest", "-q", pattern]
92
+ try:
93
+ proc = subprocess.run(
94
+ cmd,
95
+ cwd=project_dir,
96
+ capture_output=True,
97
+ text=True,
98
+ timeout=timeout,
99
+ )
100
+ result["passed"] = proc.returncode == 0
101
+ result["output"] = (proc.stdout + proc.stderr)[:500]
102
+ except subprocess.TimeoutExpired:
103
+ result["passed"] = None # timeout = pending
104
+ result["output"] = f"Timed out after {timeout}s"
105
+ except FileNotFoundError:
106
+ result["passed"] = None
107
+ result["output"] = "Test runner not found"
108
+ else:
109
+ result["passed"] = None
110
+
111
+ elif check_type == "command":
112
+ # Command checks use list form (shell=False) for safety
113
+ command = check.get("command", "")
114
+ if command:
115
+ # Split command into list safely
116
+ import shlex
117
+ try:
118
+ cmd_list = shlex.split(command)
119
+ except ValueError:
120
+ result["passed"] = None
121
+ result["output"] = "Failed to parse command"
122
+ return result
123
+ try:
124
+ proc = subprocess.run(
125
+ cmd_list,
126
+ cwd=project_dir,
127
+ capture_output=True,
128
+ text=True,
129
+ timeout=timeout,
130
+ )
131
+ result["passed"] = proc.returncode == 0
132
+ result["output"] = (proc.stdout + proc.stderr)[:500]
133
+ except subprocess.TimeoutExpired:
134
+ result["passed"] = None
135
+ result["output"] = f"Timed out after {timeout}s"
136
+ except FileNotFoundError:
137
+ result["passed"] = None
138
+ result["output"] = f"Command not found: {cmd_list[0]}"
139
+ else:
140
+ result["passed"] = None
141
+
142
+ elif check_type == "grep_codebase":
143
+ pattern = check.get("pattern", "")
144
+ if pattern and not _SAFE_PATTERN_RE.match(pattern):
145
+ result["passed"] = None
146
+ result["output"] = f"Unsafe grep pattern rejected: {pattern!r}"
147
+ elif pattern:
148
+ try:
149
+ # grep with --exclude-dir for safety (no .git, node_modules)
150
+ # Use '--' to prevent pattern being interpreted as flags
151
+ proc = subprocess.run(
152
+ ["grep", "-r", "-l",
153
+ "--exclude-dir=.git", "--exclude-dir=node_modules",
154
+ "--exclude-dir=.loki", "--exclude-dir=__pycache__",
155
+ "--", pattern, "."],
156
+ cwd=project_dir,
157
+ capture_output=True,
158
+ text=True,
159
+ timeout=timeout,
160
+ )
161
+ result["passed"] = proc.returncode == 0
162
+ files_found = proc.stdout.strip().split("\n") if proc.stdout.strip() else []
163
+ result["output"] = f"Found in {len(files_found)} file(s)"
164
+ except subprocess.TimeoutExpired:
165
+ result["passed"] = None
166
+ result["output"] = f"Timed out after {timeout}s"
167
+ else:
168
+ result["passed"] = None
169
+
170
+ elif check_type == "http_check":
171
+ path = check.get("path", "/")
172
+ # Validate path is safe
173
+ if path and not _SAFE_PATH_RE.match(path.lstrip("/")):
174
+ result["passed"] = None
175
+ result["output"] = f"Unsafe path rejected: {path!r}"
176
+ else:
177
+ # Read app runner state to get URL
178
+ app_state_file = os.path.join(project_dir, ".loki", "app-runner", "state.json")
179
+ app_url = None
180
+ if os.path.isfile(app_state_file):
181
+ try:
182
+ app_data = json.loads(Path(app_state_file).read_text())
183
+ if app_data.get("status") == "running":
184
+ app_url = app_data.get("url", "")
185
+ except (json.JSONDecodeError, OSError):
186
+ pass
187
+
188
+ if not app_url:
189
+ result["passed"] = None
190
+ result["output"] = "App not running (app runner not active)"
191
+ else:
192
+ import urllib.request
193
+ import urllib.error
194
+ target_url = app_url.rstrip("/") + "/" + path.lstrip("/")
195
+ expected_status = check.get("expected_status", 200)
196
+ try:
197
+ req = urllib.request.Request(target_url, method="GET")
198
+ resp = urllib.request.urlopen(req, timeout=min(timeout, 10))
199
+ actual_status = resp.getcode()
200
+ result["passed"] = actual_status == expected_status
201
+ result["output"] = f"HTTP {actual_status} (expected {expected_status})"
202
+ except urllib.error.HTTPError as e:
203
+ result["passed"] = e.code == expected_status
204
+ result["output"] = f"HTTP {e.code} (expected {expected_status})"
205
+ except urllib.error.URLError as e:
206
+ result["passed"] = False
207
+ result["output"] = f"Connection failed: {str(e.reason)[:100]}"
208
+ except Exception as e:
209
+ result["passed"] = None
210
+ result["output"] = f"HTTP check error: {str(e)[:100]}"
211
+
212
+ else:
213
+ result["passed"] = None
214
+ result["output"] = f"Unknown check type: {check_type}"
215
+
216
+ except Exception as e:
217
+ result["passed"] = None
218
+ result["output"] = f"Error: {str(e)[:200]}"
219
+
220
+ return result
221
+
222
+
223
+ def determine_item_status(verifications: list) -> str:
224
+ """Determine item status from its verification checks."""
225
+ if not verifications:
226
+ return "pending"
227
+
228
+ all_passed = True
229
+ any_failed = False
230
+
231
+ for v in verifications:
232
+ passed = v.get("passed")
233
+ if passed is None:
234
+ all_passed = False
235
+ elif passed is False:
236
+ any_failed = True
237
+ all_passed = False
238
+
239
+ if any_failed:
240
+ return "failing"
241
+ if all_passed:
242
+ return "verified"
243
+ return "pending"
244
+
245
+
246
+ def atomic_write_json(path: str, data: dict) -> None:
247
+ """Write JSON atomically via temp file + os.replace()."""
248
+ os.makedirs(os.path.dirname(path), exist_ok=True)
249
+ fd, tmp_path = tempfile.mkstemp(
250
+ dir=os.path.dirname(path), suffix=".tmp"
251
+ )
252
+ try:
253
+ with os.fdopen(fd, "w") as f:
254
+ json.dump(data, f, indent=2)
255
+ f.write("\n")
256
+ os.replace(tmp_path, path)
257
+ except Exception:
258
+ try:
259
+ os.unlink(tmp_path)
260
+ except OSError:
261
+ pass
262
+ raise
263
+
264
+
265
+ def main():
266
+ parser = argparse.ArgumentParser(description="PRD Checklist Verification")
267
+ parser.add_argument(
268
+ "--checklist",
269
+ default=".loki/checklist/checklist.json",
270
+ help="Path to checklist JSON (default: .loki/checklist/checklist.json)",
271
+ )
272
+ parser.add_argument(
273
+ "--timeout",
274
+ type=int,
275
+ default=30,
276
+ help="Timeout per check in seconds (default: 30)",
277
+ )
278
+ args = parser.parse_args()
279
+
280
+ checklist_path = args.checklist
281
+ if not os.path.isfile(checklist_path):
282
+ print(f"Checklist not found: {checklist_path}", file=sys.stderr)
283
+ sys.exit(1)
284
+
285
+ try:
286
+ with open(checklist_path) as f:
287
+ checklist = json.load(f)
288
+ except (json.JSONDecodeError, OSError) as e:
289
+ print(f"Failed to read checklist: {e}", file=sys.stderr)
290
+ sys.exit(1)
291
+
292
+ project_dir = os.getcwd()
293
+ now = datetime.now(timezone.utc).isoformat()
294
+
295
+ total = 0
296
+ verified = 0
297
+ failing = 0
298
+ pending = 0
299
+
300
+ for category in checklist.get("categories", []):
301
+ for item in category.get("items", []):
302
+ total += 1
303
+ verifications = item.get("verification", [])
304
+
305
+ # Run each verification check
306
+ updated_checks = []
307
+ for check in verifications:
308
+ updated = run_check(check, project_dir, args.timeout)
309
+ updated_checks.append(updated)
310
+ item["verification"] = updated_checks
311
+
312
+ # Determine item status
313
+ status = determine_item_status(updated_checks)
314
+ item["status"] = status
315
+ if status == "verified":
316
+ item["verified_at"] = now
317
+ verified += 1
318
+ elif status == "failing":
319
+ failing += 1
320
+ else:
321
+ pending += 1
322
+
323
+ # Update summary
324
+ checklist["summary"] = {
325
+ "total": total,
326
+ "verified": verified,
327
+ "failing": failing,
328
+ "pending": pending,
329
+ }
330
+ checklist["last_verified_at"] = now
331
+
332
+ # Atomic write updated checklist
333
+ atomic_write_json(checklist_path, checklist)
334
+
335
+ # Write verification results summary
336
+ results = {
337
+ "verified_at": now,
338
+ "summary": checklist["summary"],
339
+ "categories": [
340
+ {
341
+ "name": cat.get("name", ""),
342
+ "items": [
343
+ {
344
+ "id": item.get("id", ""),
345
+ "title": item.get("title", ""),
346
+ "priority": item.get("priority", "minor"),
347
+ "status": item.get("status", "pending"),
348
+ }
349
+ for item in cat.get("items", [])
350
+ ],
351
+ }
352
+ for cat in checklist.get("categories", [])
353
+ ],
354
+ }
355
+ results_path = os.path.join(
356
+ os.path.dirname(checklist_path), "verification-results.json"
357
+ )
358
+ atomic_write_json(results_path, results)
359
+
360
+ # Print summary
361
+ print(f"Checklist: {verified}/{total} verified, {failing} failing, {pending} pending")
362
+
363
+ # Exit 0 always - failures are informational, not blocking
364
+ sys.exit(0)
365
+
366
+
367
+ if __name__ == "__main__":
368
+ main()
@@ -426,6 +426,41 @@ EVIDENCE_SECTION
426
426
  if [ -f "go.mod" ]; then
427
427
  echo "- Go project detected" >> "$evidence_file"
428
428
  fi
429
+
430
+ # PRD Checklist verification evidence (v5.44.0 - advisory only)
431
+ # Uses checklist_as_evidence() from prd-checklist.sh if available
432
+ if type checklist_as_evidence &>/dev/null; then
433
+ checklist_as_evidence "$evidence_file"
434
+ elif [ -f ".loki/checklist/verification-results.json" ]; then
435
+ echo "" >> "$evidence_file"
436
+ echo "## PRD Checklist Verification Results" >> "$evidence_file"
437
+ cat ".loki/checklist/verification-results.json" >> "$evidence_file" 2>/dev/null || true
438
+ else
439
+ echo "" >> "$evidence_file"
440
+ echo "## PRD Checklist Verification Results" >> "$evidence_file"
441
+ echo "No PRD checklist has been created yet." >> "$evidence_file"
442
+ fi
443
+
444
+ # Playwright smoke test results (v5.46.0 - advisory only)
445
+ if type playwright_verify_as_evidence &>/dev/null; then
446
+ playwright_verify_as_evidence "$evidence_file"
447
+ elif [ -f ".loki/verification/playwright-results.json" ]; then
448
+ echo "" >> "$evidence_file"
449
+ echo "## Playwright Smoke Test Results" >> "$evidence_file"
450
+ _PW_RESULTS=".loki/verification/playwright-results.json" python3 -c "
451
+ import json, os
452
+ try:
453
+ d = json.load(open(os.environ['_PW_RESULTS']))
454
+ status = 'PASSED' if d.get('passed') else 'FAILED'
455
+ print(f'Status: {status}')
456
+ for k, v in d.get('checks', {}).items():
457
+ icon = '[PASS]' if v else '[FAIL]'
458
+ print(f' {icon} {k}')
459
+ for e in d.get('errors', [])[:5]:
460
+ print(f' Error: {e}')
461
+ except: print('Results unavailable')
462
+ " >> "$evidence_file" 2>/dev/null || echo "Playwright data unavailable" >> "$evidence_file"
463
+ fi
429
464
  }
430
465
 
431
466
  #===============================================================================