@yottameta/yotta-dev-mcp-plugin 0.1.1 → 0.2.1

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,450 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """L0-L5 verification ledger for yotta-dev-mcp."""
4
+
5
+ import ast
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import dev_contract
11
+ from dev_common import EVIDENCE_LIMIT, JS_EXTS, VERIFY_EXEC_LEVELS, VERIFY_LEVELS
12
+ from dev_impact import impact_analysis
13
+
14
+
15
+ def _run_checks_lazy(kind, cwd, timeout, allow_execute):
16
+ from dev_engine import run_checks
17
+ return run_checks(kind, cwd, timeout=timeout, allow_execute=allow_execute)
18
+
19
+
20
+ def _is_within(root, target):
21
+ try:
22
+ Path(target).resolve().relative_to(Path(root).resolve())
23
+ return True
24
+ except ValueError:
25
+ return False
26
+
27
+ def _ledger_entry(check_id, level, claim, status, severity="info", evidence=None,
28
+ next_step=None, command=None, confidence="high"):
29
+ return {
30
+ "id": check_id,
31
+ "level": level,
32
+ "claim": claim,
33
+ "status": status,
34
+ "severity": severity,
35
+ "check": "verify_change",
36
+ "confidence": confidence,
37
+ "evidence": sorted(
38
+ list(evidence or []),
39
+ key=lambda item: (item.get("path") or "", item.get("line") or 0,
40
+ item.get("detail") or ""),
41
+ ),
42
+ "next_step": next_step,
43
+ "command": command,
44
+ }
45
+
46
+ def _verify_contract_result(contract_result):
47
+ if not contract_result["present"]:
48
+ return "UNKNOWN", [{
49
+ "path": contract_result["path"],
50
+ "line": None,
51
+ "detail": "architecture contract is missing",
52
+ }], "add .yotta/architecture.json"
53
+ if not contract_result["ok"]:
54
+ evidence = []
55
+ for item in contract_result["findings"]:
56
+ if item["severity"] in dev_contract.BLOCKING_SEVERITIES:
57
+ evidence.append({
58
+ "path": item["path"],
59
+ "line": None,
60
+ "detail": "%s: %s" % (item["code"], item["message"]),
61
+ })
62
+ return "FAIL", evidence, "fix the contract findings and rerun verify_change"
63
+ return "PASS", [], None
64
+
65
+ def _verify_syntax(changed, root):
66
+ evidence = []
67
+ unknown = []
68
+ for item in changed:
69
+ rel = item["path"]
70
+ if item.get("change") == "deleted":
71
+ continue
72
+ target = root / rel
73
+ if not target.is_file():
74
+ continue
75
+ suffix = target.suffix.lower()
76
+ if suffix not in (".py", ".json") and suffix not in JS_EXTS:
77
+ continue
78
+ if suffix in JS_EXTS:
79
+ unknown.append({
80
+ "path": rel,
81
+ "line": None,
82
+ "detail": "no zero-dependency JavaScript parser is available at L0",
83
+ })
84
+ continue
85
+ try:
86
+ text = target.read_text(encoding="utf-8")
87
+ except (OSError, UnicodeError) as exc:
88
+ unknown.append({
89
+ "path": rel,
90
+ "line": None,
91
+ "detail": "file could not be read: %s" % exc,
92
+ })
93
+ continue
94
+ try:
95
+ if suffix == ".py":
96
+ ast.parse(text, filename=rel)
97
+ else:
98
+ json.loads(text)
99
+ except SyntaxError as exc:
100
+ evidence.append({
101
+ "path": rel,
102
+ "line": exc.lineno,
103
+ "detail": "syntax error: %s" % exc.msg,
104
+ })
105
+ except json.JSONDecodeError as exc:
106
+ evidence.append({
107
+ "path": rel,
108
+ "line": exc.lineno,
109
+ "detail": "JSON error: %s" % exc.msg,
110
+ })
111
+ if evidence:
112
+ return "FAIL", evidence, "fix the syntax error(s) and rerun verify_change", []
113
+ if unknown:
114
+ return "UNKNOWN", unknown, "use an L2-L4 adapter or a language-specific parser", unknown
115
+ return "PASS", [], None, []
116
+
117
+ def _verify_architecture(impact):
118
+ architecture = impact["architecture"]
119
+ blocking = [
120
+ item for item in architecture["violations_in_scope"]
121
+ if item["severity"] in dev_contract.BLOCKING_SEVERITIES
122
+ ]
123
+ if blocking:
124
+ evidence = []
125
+ for item in blocking:
126
+ for entry in item["evidence"]:
127
+ evidence.append({
128
+ "path": entry["path"],
129
+ "line": entry.get("line"),
130
+ "detail": "%s (%s): %s" % (
131
+ item["rule"], item["code"], entry.get("detail") or "",
132
+ ),
133
+ })
134
+ return "FAIL", evidence, "fix the in-scope architecture violation(s)"
135
+ scoped = {item["path"] for item in impact["cone"]["nodes"]}
136
+ always_relevant = {
137
+ "contract-missing", "contract-invalid", "model-truncated", "cone-truncated",
138
+ "change-not-found", "symbol-not-found",
139
+ }
140
+ unknowns = []
141
+ for item in list(architecture.get("unknowns") or []) + list(impact.get("unknowns") or []):
142
+ kind = item.get("kind")
143
+ identifier = str(item.get("id") or "")
144
+ if kind in always_relevant or not identifier:
145
+ unknowns.append(item)
146
+ elif identifier in scoped:
147
+ unknowns.append(item)
148
+ elif any(path and path in identifier for path in scoped):
149
+ unknowns.append(item)
150
+ if unknowns:
151
+ evidence = [{
152
+ "path": item.get("id") or item.get("path") or "",
153
+ "line": None,
154
+ "detail": "%s: %s" % (item.get("kind"), item.get("detail") or ""),
155
+ } for item in unknowns]
156
+ return "UNKNOWN", evidence, "resolve the unknown evidence and rerun verify_change"
157
+ return "PASS", [], None
158
+
159
+ def _verify_policy_entries(root, policy, execution_levels, allow_execute, timeout):
160
+ ledger = []
161
+ unverified = []
162
+ for level in execution_levels:
163
+ checks = [item for item in policy["checks"] if item["level"] == level]
164
+ if not checks:
165
+ ledger.append(_ledger_entry(
166
+ "%s-policy" % level, level,
167
+ "%s verification is declared" % level,
168
+ "UNKNOWN", severity="high",
169
+ evidence=[{"path": policy["path"], "line": None,
170
+ "detail": "no %s checks are declared" % level}],
171
+ next_step="declare a whitelisted %s check in .yotta/verification.json" % level,
172
+ ))
173
+ unverified.append({
174
+ "level": level,
175
+ "claim": "%s verification is declared and executed" % level,
176
+ "status": "UNVERIFIED",
177
+ "reason": "verification-level-not-declared",
178
+ "required": True,
179
+ "next_step": "declare a whitelisted check in .yotta/verification.json",
180
+ })
181
+ continue
182
+ for check in checks:
183
+ check_id = "%s-%s" % (level, check["id"])
184
+ claim = check["claim"] or "%s check %s passes" % (level, check["id"])
185
+ if not allow_execute:
186
+ ledger.append(_ledger_entry(
187
+ check_id, level, claim, "UNVERIFIED",
188
+ severity="high" if check["required"] else "medium",
189
+ next_step="rerun with allow_execute=true",
190
+ ))
191
+ unverified.append({
192
+ "level": level,
193
+ "claim": claim,
194
+ "status": "UNVERIFIED",
195
+ "reason": "allow_execute=false",
196
+ "required": check["required"],
197
+ "next_step": "rerun with allow_execute=true",
198
+ })
199
+ continue
200
+ cwd = (root / check["cwd"]).resolve()
201
+ if not _is_within(root, cwd) or not cwd.is_dir():
202
+ ledger.append(_ledger_entry(
203
+ check_id, level, claim, "FAIL", severity="high",
204
+ evidence=[{"path": check["cwd"], "line": None,
205
+ "detail": "check cwd is not a directory inside the repository"}],
206
+ next_step="fix the policy cwd and rerun verify_change",
207
+ ))
208
+ continue
209
+ try:
210
+ result = _run_checks_lazy(
211
+ check["kind"], str(cwd),
212
+ timeout=min(timeout, check["timeout"]),
213
+ allow_execute=True,
214
+ )
215
+ except Exception as exc: # noqa: BLE001
216
+ ledger.append(_ledger_entry(
217
+ check_id, level, claim, "FAIL", severity="high",
218
+ evidence=[{"path": check["cwd"], "line": None,
219
+ "detail": "check could not run: %s" % exc}],
220
+ next_step="fix the verification policy or local toolchain",
221
+ ))
222
+ continue
223
+ output = result.get("output") or ""
224
+ command = {
225
+ "kind": result["kind"],
226
+ "cwd": check["cwd"],
227
+ "exit_code": result["exit_code"],
228
+ "output_hash": hashlib.sha256(output.encode("utf-8")).hexdigest(),
229
+ "summary": result.get("summary") or "",
230
+ "timed_out": bool(result.get("timed_out")),
231
+ }
232
+ status = "PASS" if result.get("passed") else "FAIL"
233
+ ledger.append(_ledger_entry(
234
+ check_id, level, claim, status,
235
+ severity="high" if check["required"] else "medium",
236
+ evidence=[{"path": check["cwd"], "line": None,
237
+ "detail": result.get("summary") or "check finished"}],
238
+ next_step=None if status == "PASS"
239
+ else "inspect the check output and fix the failure",
240
+ command=command,
241
+ ))
242
+ return sorted(ledger, key=lambda item: (item["level"], item["id"])), unverified
243
+
244
+ def verify_change(path, changed_files=None, diff=None, symbols=None, depth=3,
245
+ levels=None, allow_execute=False, timeout=120,
246
+ max_files=2000, contract_file=None, policy_file=None):
247
+ """Run the L0-L5 verification ladder and return a deterministic evidence ledger.
248
+
249
+ L0/L1 always run in-process. L2-L4 run only when both a whitelisted
250
+ .yotta/verification.json check is declared and allow_execute is true.
251
+ L5 is always recorded as manual work and is never auto-verified.
252
+ """
253
+ root = Path(path)
254
+ if not root.exists():
255
+ raise ValueError("路径不存在: %s" % path)
256
+ if not root.is_dir():
257
+ raise ValueError("verify_change 需要目录: %s" % path)
258
+ if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 600:
259
+ raise ValueError("timeout 必须是 1 到 600 之间的整数")
260
+ requested = list(levels or [])
261
+ for level in requested:
262
+ if level not in VERIFY_LEVELS:
263
+ raise ValueError("level 必须是 L0-L5 之一: %s" % level)
264
+ execution_levels = [level for level in VERIFY_EXEC_LEVELS if level in requested]
265
+
266
+ impact = impact_analysis(
267
+ str(root), changed_files=changed_files, diff=diff, symbols=symbols,
268
+ depth=depth, max_files=max_files, contract_file=contract_file,
269
+ )
270
+ contract_result = dev_contract.load_contract(root, contract_file=contract_file)
271
+ policy = dev_contract.load_verification_policy(root, policy_file=policy_file)
272
+
273
+ ledger = []
274
+ unverified = []
275
+
276
+ contract_status, contract_evidence, contract_next = _verify_contract_result(contract_result)
277
+ ledger.append(_ledger_entry(
278
+ "L0-contract", "L0", "the architecture contract is valid",
279
+ contract_status, severity="high",
280
+ evidence=contract_evidence, next_step=contract_next,
281
+ ))
282
+
283
+ syntax_status, syntax_evidence, syntax_next, syntax_unknown = _verify_syntax(
284
+ impact["changed"], root
285
+ )
286
+ ledger.append(_ledger_entry(
287
+ "L0-syntax", "L0", "changed source files parse",
288
+ syntax_status, severity="high",
289
+ evidence=syntax_evidence, next_step=syntax_next,
290
+ ))
291
+ if syntax_unknown:
292
+ unverified.append({
293
+ "level": "L0",
294
+ "claim": "changed JavaScript or TypeScript files parse",
295
+ "status": "UNVERIFIED",
296
+ "reason": "no zero-dependency JavaScript parser",
297
+ "required": False,
298
+ "next_step": "add a language adapter or run an explicit parser check",
299
+ })
300
+
301
+ architecture_status, architecture_evidence, architecture_next = _verify_architecture(impact)
302
+ advisory = [
303
+ item for item in impact["architecture"]["violations_in_scope"]
304
+ if item["severity"] not in dev_contract.BLOCKING_SEVERITIES
305
+ ]
306
+ if architecture_status == "PASS" and advisory:
307
+ architecture_evidence = [{
308
+ "path": entry["path"],
309
+ "line": entry.get("line"),
310
+ "detail": "%s (%s): %s" % (
311
+ item["rule"], item["code"], entry.get("detail") or "",
312
+ ),
313
+ } for item in advisory for entry in item["evidence"]]
314
+ ledger.append(_ledger_entry(
315
+ "L1-architecture", "L1", "in-scope architecture rules and boundaries hold",
316
+ architecture_status, severity="high",
317
+ evidence=architecture_evidence, next_step=architecture_next,
318
+ ))
319
+
320
+ for invariant in impact["affected_invariants"]:
321
+ unverified.append({
322
+ "level": "L1",
323
+ "claim": invariant["claim"],
324
+ "status": "UNVERIFIED",
325
+ "reason": "invariant requires a static, command or manual check",
326
+ "required": False,
327
+ "next_step": "add a verification policy check or complete the manual review",
328
+ })
329
+
330
+ if execution_levels:
331
+ if not policy["present"]:
332
+ for level in execution_levels:
333
+ ledger.append(_ledger_entry(
334
+ "%s-policy" % level, level,
335
+ "%s verification policy is present" % level,
336
+ "UNKNOWN", severity="high",
337
+ evidence=[{"path": policy["path"], "line": None,
338
+ "detail": "verification policy is missing"}],
339
+ next_step="add .yotta/verification.json with whitelisted checks",
340
+ ))
341
+ unverified.append({
342
+ "level": level,
343
+ "claim": "%s verification is declared and executed" % level,
344
+ "status": "UNVERIFIED",
345
+ "reason": "verification-policy-missing",
346
+ "required": True,
347
+ "next_step": "add .yotta/verification.json",
348
+ })
349
+ elif not policy["ok"]:
350
+ evidence = [{
351
+ "path": item["path"],
352
+ "line": None,
353
+ "detail": "%s: %s" % (item["code"], item["message"]),
354
+ } for item in policy["findings"]
355
+ if item["severity"] in dev_contract.BLOCKING_SEVERITIES]
356
+ for level in execution_levels:
357
+ ledger.append(_ledger_entry(
358
+ "%s-policy" % level, level,
359
+ "%s verification policy is valid" % level,
360
+ "FAIL", severity="high", evidence=evidence,
361
+ next_step="fix .yotta/verification.json and rerun verify_change",
362
+ ))
363
+ else:
364
+ execution_ledger, execution_unverified = _verify_policy_entries(
365
+ root, policy, execution_levels, allow_execute, timeout,
366
+ )
367
+ ledger.extend(execution_ledger)
368
+ unverified.extend(execution_unverified)
369
+
370
+ verified_levels = {
371
+ item["level"] for item in ledger
372
+ if item["status"] in ("PASS", "FAIL")
373
+ }
374
+ for level in VERIFY_EXEC_LEVELS:
375
+ if level not in verified_levels and not any(
376
+ item["level"] == level for item in unverified
377
+ ):
378
+ unverified.append({
379
+ "level": level,
380
+ "claim": "%s verification level is covered by evidence" % level,
381
+ "status": "UNVERIFIED",
382
+ "reason": "level-not-requested",
383
+ "required": False,
384
+ "next_step": "request this level and provide a verification policy",
385
+ })
386
+ if "L5" not in requested:
387
+ unverified.append({
388
+ "level": "L5",
389
+ "claim": "independent review or manual architecture approval",
390
+ "status": "UNVERIFIED",
391
+ "reason": "manual verification required",
392
+ "required": False,
393
+ "next_step": "complete an independent review or manual architecture decision",
394
+ })
395
+ else:
396
+ unverified.append({
397
+ "level": "L5",
398
+ "claim": "independent review or manual architecture approval",
399
+ "status": "UNVERIFIED",
400
+ "reason": "manual verification required",
401
+ "required": True,
402
+ "next_step": "complete an independent review or manual architecture decision",
403
+ })
404
+
405
+ unverified.sort(key=lambda item: (item["level"], item["claim"], item["reason"]))
406
+ ledger.sort(key=lambda item: (item["level"], item["id"]))
407
+ failed = any(item["status"] == "FAIL" for item in ledger)
408
+ unknown = any(item["status"] == "UNKNOWN" for item in ledger) or any(
409
+ item["required"] for item in unverified
410
+ )
411
+ if failed:
412
+ status = "FAIL"
413
+ elif unknown:
414
+ status = "UNKNOWN"
415
+ else:
416
+ status = "PASS"
417
+ required_levels = ["L0", "L1"]
418
+ for item in ledger:
419
+ if item["level"] in VERIFY_EXEC_LEVELS and item["status"] in ("PASS", "FAIL"):
420
+ required_levels.append(item["level"])
421
+ required_levels = sorted(set(required_levels), key=lambda item: int(item[1:]))
422
+ evidence = []
423
+ for item in ledger:
424
+ evidence.extend(item["evidence"])
425
+ if len(evidence) > EVIDENCE_LIMIT:
426
+ evidence = evidence[:EVIDENCE_LIMIT]
427
+ ledger_digest = hashlib.sha256(
428
+ json.dumps(ledger, ensure_ascii=False, sort_keys=True,
429
+ separators=(",", ":")).encode("utf-8")
430
+ ).hexdigest()
431
+ return {
432
+ "status": status,
433
+ "root": str(root.resolve()),
434
+ "inputs": {
435
+ "changed_files": list(changed_files or []),
436
+ "symbols": list(symbols or []),
437
+ "diff_provided": bool(str(diff or "").strip()),
438
+ "levels": requested,
439
+ "allow_execute": bool(allow_execute),
440
+ "depth": depth,
441
+ },
442
+ "required_levels": required_levels,
443
+ "ledger": ledger,
444
+ "unverified_claims": unverified,
445
+ "policy": policy,
446
+ "evidence": evidence,
447
+ "ledger_digest": ledger_digest,
448
+ "model_digest": impact["model_digest"],
449
+ "impact_status": impact["status"],
450
+ }