@yottameta/yotta-dev-mcp-plugin 0.1.1 → 0.2.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,534 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Self-test and counterexample probes for yotta-dev-mcp."""
4
+
5
+ import ast
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from dev_architecture import architecture_review
17
+ from dev_common import (
18
+ EVIDENCE_LIMIT, VERIFY_REQUIRED_INSTALLED_FILES,
19
+ VERIFY_REQUIRED_SOURCE_FILES, VERIFY_WRITE_GATES, _frontmatter_version,
20
+ _read_text,
21
+ )
22
+
23
+
24
+ def _function_default(source_text, function_name, parameter_name):
25
+ try:
26
+ tree = ast.parse(source_text)
27
+ except SyntaxError:
28
+ return False, None
29
+ for node in tree.body:
30
+ if not isinstance(node, ast.FunctionDef) or node.name != function_name:
31
+ continue
32
+ positional = list(node.args.args)
33
+ defaults = list(node.args.defaults)
34
+ offset = len(positional) - len(defaults)
35
+ for index, argument in enumerate(positional):
36
+ if argument.arg != parameter_name or index < offset:
37
+ continue
38
+ try:
39
+ return True, ast.literal_eval(defaults[index - offset])
40
+ except (ValueError, SyntaxError):
41
+ return True, "<non-literal>"
42
+ return False, None
43
+
44
+ def _string_constant(node):
45
+ return isinstance(node, ast.Constant) and isinstance(node.value, str)
46
+
47
+ def _protocol_tool_contracts(source_text):
48
+ tree = ast.parse(source_text)
49
+ function = None
50
+ for node in tree.body:
51
+ if isinstance(node, ast.FunctionDef) and node.name == "mcp_tools":
52
+ function = node
53
+ break
54
+ if function is None:
55
+ raise ValueError("mcp_tools() not found")
56
+ list_node = None
57
+ for node in ast.walk(function):
58
+ if isinstance(node, ast.Return) and isinstance(node.value, ast.List):
59
+ list_node = node.value
60
+ break
61
+ if list_node is None:
62
+ raise ValueError("mcp_tools() does not return a literal tool list")
63
+ contracts = []
64
+ for element in list_node.elts:
65
+ if not isinstance(element, ast.Dict):
66
+ raise ValueError("mcp_tools() contains a non-literal tool entry")
67
+ values = {}
68
+ for key, value in zip(element.keys, element.values):
69
+ if _string_constant(key):
70
+ values[key.value] = value
71
+ name_node = values.get("name")
72
+ if name_node is None:
73
+ raise ValueError("tool entry is missing name")
74
+ try:
75
+ name = ast.literal_eval(name_node)
76
+ except (ValueError, SyntaxError):
77
+ raise ValueError("tool name is not a literal string")
78
+ schema = values.get("inputSchema")
79
+ additional = False
80
+ if isinstance(schema, ast.Dict):
81
+ for key, value in zip(schema.keys, schema.values):
82
+ if _string_constant(key) and key.value == "additionalProperties":
83
+ try:
84
+ additional = ast.literal_eval(value) is False
85
+ except (ValueError, SyntaxError):
86
+ additional = False
87
+ contracts.append({"name": name, "additional_properties": additional})
88
+ return contracts
89
+
90
+ def _dispatch_tool_names(source_text):
91
+ tree = ast.parse(source_text)
92
+ for node in tree.body:
93
+ if not isinstance(node, ast.FunctionDef) or node.name != "dispatch":
94
+ continue
95
+ for child in ast.walk(node):
96
+ if not isinstance(child, ast.Assign):
97
+ continue
98
+ if not any(isinstance(target, ast.Name) and target.id == "handlers"
99
+ for target in child.targets):
100
+ continue
101
+ if not isinstance(child.value, ast.Dict):
102
+ continue
103
+ names = []
104
+ for key in child.value.keys:
105
+ if _string_constant(key):
106
+ names.append(key.value)
107
+ else:
108
+ try:
109
+ names.append(ast.literal_eval(key))
110
+ except (ValueError, SyntaxError):
111
+ pass
112
+ return sorted(names)
113
+ raise ValueError("dispatch() handler map not found")
114
+
115
+ def _self_test_counterexamples():
116
+ probes = []
117
+
118
+ def record(kind, name, expectation, observed, passed, detail):
119
+ probes.append({
120
+ "kind": kind,
121
+ "name": name,
122
+ "expectation": expectation,
123
+ "observed": observed,
124
+ "passed": bool(passed),
125
+ "detail": detail,
126
+ })
127
+
128
+ with tempfile.TemporaryDirectory(prefix="yotta-dev-mcp-selftest-") as tmp:
129
+ root = Path(tmp)
130
+
131
+ def write(rel, text):
132
+ target = root / rel
133
+ target.parent.mkdir(parents=True, exist_ok=True)
134
+ target.write_text(text, encoding="utf-8")
135
+
136
+ contract = {
137
+ "version": 1,
138
+ "layers": [
139
+ {"id": "core", "paths": ["core/**"]},
140
+ {"id": "ui", "paths": ["ui/**"]},
141
+ ],
142
+ "rules": [{
143
+ "id": "core-no-ui",
144
+ "type": "forbid-dependency",
145
+ "from": "core",
146
+ "to": "ui",
147
+ "severity": "high",
148
+ "claim": "core must not import ui",
149
+ }],
150
+ }
151
+ write(".yotta/architecture.json", json.dumps(contract))
152
+ write("core/leak.py", "from ui.view import VALUE\n")
153
+ write("ui/view.py", "VALUE = 1\n")
154
+
155
+ try:
156
+ observed = architecture_review(str(root))["status"]
157
+ detail = "seeded core -> ui dependency was reviewed"
158
+ except Exception as exc: # noqa: BLE001
159
+ observed = "error"
160
+ detail = str(exc)
161
+ record("seeded-defect", "forbidden dependency", "FAIL", observed,
162
+ observed == "FAIL", detail)
163
+
164
+ contract["rules"] = []
165
+ write(".yotta/architecture.json", json.dumps(contract))
166
+ try:
167
+ observed = architecture_review(str(root))["status"]
168
+ detail = "same defect with the rule removed"
169
+ except Exception as exc: # noqa: BLE001
170
+ observed = "error"
171
+ detail = str(exc)
172
+ record("mutation-control", "remove the rule", "not FAIL", observed,
173
+ observed != "FAIL", detail)
174
+
175
+ contract["version"] = 3
176
+ write(".yotta/architecture.json", json.dumps(contract))
177
+ try:
178
+ observed = architecture_review(str(root))["status"]
179
+ detail = "unsupported contract version"
180
+ except Exception as exc: # noqa: BLE001
181
+ observed = "error"
182
+ detail = str(exc)
183
+ record("invalid-contract", "unsupported version", "FAIL or UNKNOWN",
184
+ observed, observed in ("FAIL", "UNKNOWN"), detail)
185
+
186
+ (root / ".yotta" / "architecture.json").unlink()
187
+ try:
188
+ observed = architecture_review(str(root))["status"]
189
+ detail = "missing contract"
190
+ except Exception as exc: # noqa: BLE001
191
+ observed = "error"
192
+ detail = str(exc)
193
+ record("missing-contract", "no architecture contract", "UNKNOWN",
194
+ observed, observed == "UNKNOWN", detail)
195
+
196
+ write("secret.env", "TOKEN=abcdefghijklmnopqrstuvwxyz123456\n")
197
+ try:
198
+ from dev_engine import scan_secrets
199
+ observed = len(scan_secrets(str(root))["findings"])
200
+ passed = observed >= 1
201
+ detail = "seeded token was scanned"
202
+ except Exception as exc: # noqa: BLE001
203
+ observed = "error"
204
+ passed = False
205
+ detail = str(exc)
206
+ record("secret-scan", "seeded credential", "at least one finding",
207
+ observed, passed, detail)
208
+
209
+ write("package.json", json.dumps({"name": "probe", "version": "1.0.0"}))
210
+ write("SKILL.md", "---\nname: probe\nversion: 2.0.0\n---\n")
211
+ write("README.md", "# probe\n")
212
+ write("LICENSE", "MIT\n")
213
+ write("CHANGELOG.md", "## v1.0.0\n")
214
+ try:
215
+ from dev_engine import check_publish_readiness
216
+ readiness = check_publish_readiness(str(root))
217
+ codes = {item["code"] for item in readiness["issues"]}
218
+ observed = "FAIL" if not readiness["ok"] else "PASS"
219
+ passed = not readiness["ok"] and "version-mismatch" in codes
220
+ detail = "seeded package/SKILL version mismatch"
221
+ except Exception as exc: # noqa: BLE001
222
+ observed = "error"
223
+ passed = False
224
+ detail = str(exc)
225
+ record("publish-check", "seeded version mismatch", "FAIL",
226
+ observed, passed, detail)
227
+ return probes
228
+
229
+ def _self_test_version_check(root, mode):
230
+ if mode == "installed":
231
+ skill = root / "SKILL.md"
232
+ version = _frontmatter_version(_read_text(skill)) if skill.is_file() else None
233
+ if not version:
234
+ return "FAIL", [{"code": "missing-version", "path": "SKILL.md",
235
+ "detail": "SKILL.md has no version field"}], \
236
+ "restore a valid SKILL.md version"
237
+ return "PASS", [], None
238
+
239
+ versions = {}
240
+ try:
241
+ package = json.loads(_read_text(root / "package.json"))
242
+ versions["package.json"] = package.get("version")
243
+ except Exception as exc: # noqa: BLE001
244
+ return "FAIL", [{"code": "invalid-package-json", "path": "package.json",
245
+ "detail": str(exc)}], "fix package.json"
246
+ for rel in ("SKILL.md", "CHANGELOG.md", "server.json"):
247
+ target = root / rel
248
+ if not target.is_file():
249
+ versions[rel] = None
250
+ continue
251
+ if rel == "server.json":
252
+ try:
253
+ versions[rel] = json.loads(_read_text(target)).get("version")
254
+ except Exception: # noqa: BLE001
255
+ versions[rel] = None
256
+ elif rel == "CHANGELOG.md":
257
+ match = re.search(r"(?m)^##\s+v?(\d+\.\d+\.\d+)", _read_text(target))
258
+ versions[rel] = match.group(1) if match else None
259
+ else:
260
+ versions[rel] = _frontmatter_version(_read_text(target))
261
+ engine_path = root / "scripts" / "dev_engine.py"
262
+ if engine_path.is_file():
263
+ match = re.search(r'(?m)^VERSION\s*=\s*["\']([^"\']+)["\']',
264
+ _read_text(engine_path))
265
+ versions["engine"] = match.group(1) if match else None
266
+ else:
267
+ versions["engine"] = None
268
+ missing = sorted(key for key, value in versions.items() if not value)
269
+ if missing:
270
+ return "FAIL", [{
271
+ "code": "missing-version", "path": missing[0],
272
+ "detail": "version is missing from: %s" % ", ".join(missing),
273
+ }], "restore version alignment"
274
+ values = {value for value in versions.values() if value}
275
+ if len(values) != 1:
276
+ return "FAIL", [{
277
+ "code": "version-mismatch", "path": "package.json",
278
+ "detail": json.dumps(versions, ensure_ascii=False, sort_keys=True),
279
+ }], "align package, SKILL, CHANGELOG, server and engine versions"
280
+ return "PASS", [], None
281
+
282
+ def _self_test_tool_contracts(root):
283
+ protocol_path = root / "scripts" / "yotta_dev_mcp.py"
284
+ engine_path = root / "scripts" / "dev_engine.py"
285
+ if not protocol_path.is_file() or not engine_path.is_file():
286
+ return "FAIL", [{
287
+ "code": "missing-protocol-source", "path": "scripts",
288
+ "detail": "protocol or engine source is missing",
289
+ }], "restore the protocol and engine sources"
290
+ try:
291
+ contracts = _protocol_tool_contracts(_read_text(protocol_path))
292
+ dispatch_names = _dispatch_tool_names(_read_text(engine_path))
293
+ except (OSError, ValueError, SyntaxError) as exc:
294
+ return "FAIL", [{
295
+ "code": "tool-contract-parse-error", "path": "scripts",
296
+ "detail": str(exc),
297
+ }], "fix the protocol or engine source"
298
+ protocol_names = [item["name"] for item in contracts]
299
+ evidence = []
300
+ if len(protocol_names) != len(set(protocol_names)):
301
+ evidence.append({"code": "tool-name-duplicate", "path": "scripts/yotta_dev_mcp.py",
302
+ "detail": "duplicate tool names in mcp_tools()"})
303
+ if set(protocol_names) != set(dispatch_names):
304
+ evidence.append({
305
+ "code": "tool-name-drift", "path": "scripts/yotta_dev_mcp.py",
306
+ "detail": "protocol=%s dispatch=%s" % (
307
+ ",".join(sorted(protocol_names)), ",".join(sorted(dispatch_names)),
308
+ ),
309
+ })
310
+ drifted_schema = sorted(
311
+ item["name"] for item in contracts if not item["additional_properties"]
312
+ )
313
+ if drifted_schema:
314
+ evidence.append({
315
+ "code": "tool-schema-drift", "path": "scripts/yotta_dev_mcp.py",
316
+ "detail": "inputSchema.additionalProperties is not false for: %s"
317
+ % ", ".join(drifted_schema),
318
+ })
319
+ if evidence:
320
+ return "FAIL", evidence, "align the protocol schemas with the engine handlers"
321
+ return "PASS", [], None
322
+
323
+ def _self_test_write_gates(root):
324
+ engine_path = root / "scripts" / "dev_engine.py"
325
+ if not engine_path.is_file():
326
+ return "FAIL", [{"code": "missing-engine-source", "path": "scripts/dev_engine.py",
327
+ "detail": "engine source is missing"}], "restore the engine source"
328
+ text = _read_text(engine_path)
329
+ evidence = []
330
+ for function_name, parameter in VERIFY_WRITE_GATES:
331
+ found, value = _function_default(text, function_name, parameter)
332
+ if not found:
333
+ evidence.append({
334
+ "code": "write-gate-missing",
335
+ "path": "scripts/dev_engine.py",
336
+ "detail": "%s(%s=...) was not found" % (function_name, parameter),
337
+ })
338
+ elif value is not False:
339
+ evidence.append({
340
+ "code": "write-gate-drift",
341
+ "path": "scripts/dev_engine.py",
342
+ "detail": "%s.%s default is %r, expected False"
343
+ % (function_name, parameter, value),
344
+ })
345
+ if evidence:
346
+ return "FAIL", evidence, "restore fail-closed defaults for write and execute gates"
347
+ return "PASS", [], None
348
+
349
+ def _self_test_detect_test_kind(root):
350
+ package_path = root / "package.json"
351
+ if package_path.is_file():
352
+ try:
353
+ package = json.loads(_read_text(package_path))
354
+ scripts = package.get("scripts") or {}
355
+ if isinstance(scripts, dict) and scripts.get("test"):
356
+ return "npm-test"
357
+ except Exception: # noqa: BLE001
358
+ pass
359
+ if list(root.glob("test*.py")) or list(root.glob("tests/test*.py")):
360
+ return "python-unittest"
361
+ return None
362
+
363
+ def self_test(path, mode="auto", allow_execute=False, timeout=120):
364
+ """Run deterministic integrity and counterexample checks on yotta-dev-mcp itself."""
365
+ root = Path(path)
366
+ if not root.exists():
367
+ raise ValueError("路径不存在: %s" % path)
368
+ if not root.is_dir():
369
+ raise ValueError("self_test 需要目录: %s" % path)
370
+ if mode not in ("auto", "source", "installed"):
371
+ raise ValueError("mode 必须是 auto、source 或 installed")
372
+ if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 600:
373
+ raise ValueError("timeout 必须是 1 到 600 之间的整数")
374
+ if mode == "auto":
375
+ if ((root / "package.json").is_file()
376
+ or (root / "scripts" / "yotta_dev_mcp.py").is_file()):
377
+ mode = "source"
378
+ elif (root / "SKILL.md").is_file():
379
+ mode = "installed"
380
+ else:
381
+ mode = "source"
382
+
383
+ required = (VERIFY_REQUIRED_SOURCE_FILES if mode == "source"
384
+ else VERIFY_REQUIRED_INSTALLED_FILES)
385
+ missing = [rel for rel in required if not (root / rel).is_file()]
386
+ files_check = {
387
+ "id": "files",
388
+ "status": "FAIL" if missing else "PASS",
389
+ "severity": "high",
390
+ "claim": "required %s files are present" % mode,
391
+ "evidence": [{
392
+ "code": "missing-file", "path": rel,
393
+ "detail": "required file is missing",
394
+ } for rel in missing],
395
+ "next_step": "restore the missing files" if missing else None,
396
+ }
397
+
398
+ version_status, version_evidence, version_next = _self_test_version_check(root, mode)
399
+ version_check = {
400
+ "id": "versions" if mode == "source" else "skill-version",
401
+ "status": version_status,
402
+ "severity": "high",
403
+ "claim": ("package, SKILL, CHANGELOG, server and engine versions align"
404
+ if mode == "source" else "SKILL.md declares a version"),
405
+ "evidence": version_evidence,
406
+ "next_step": version_next,
407
+ }
408
+
409
+ checks = [files_check, version_check]
410
+ unverified = []
411
+ if mode == "source":
412
+ tool_status, tool_evidence, tool_next = _self_test_tool_contracts(root)
413
+ checks.append({
414
+ "id": "tool-contracts",
415
+ "status": tool_status,
416
+ "severity": "high",
417
+ "claim": "protocol tool schemas match the engine dispatch handlers",
418
+ "evidence": tool_evidence,
419
+ "next_step": tool_next,
420
+ })
421
+ gate_status, gate_evidence, gate_next = _self_test_write_gates(root)
422
+ checks.append({
423
+ "id": "write-gates",
424
+ "status": gate_status,
425
+ "severity": "high",
426
+ "claim": "write and execute gates default to fail-closed",
427
+ "evidence": gate_evidence,
428
+ "next_step": gate_next,
429
+ })
430
+ else:
431
+ unverified.extend([
432
+ {"level": "L0", "claim": "protocol tool schemas match engine handlers",
433
+ "status": "UNVERIFIED", "reason": "installed mode has no protocol source",
434
+ "required": False, "next_step": "run self_test on the source checkout"},
435
+ {"level": "L0", "claim": "write and execute gates default to fail-closed",
436
+ "status": "UNVERIFIED", "reason": "installed mode has no engine source",
437
+ "required": False, "next_step": "run self_test on the source checkout"},
438
+ ])
439
+
440
+ probes = _self_test_counterexamples()
441
+ probe_failed = [item for item in probes if not item["passed"]]
442
+ checks.append({
443
+ "id": "verifier-counterexamples",
444
+ "status": "FAIL" if probe_failed else "PASS",
445
+ "severity": "high",
446
+ "claim": "seeded defects and mutation controls turn the verifier red or unknown",
447
+ "evidence": probes,
448
+ "next_step": "fix the verifier so every counterexample is detected"
449
+ if probe_failed else None,
450
+ })
451
+
452
+ if allow_execute:
453
+ kind = _self_test_detect_test_kind(root)
454
+ if kind is None:
455
+ checks.append({
456
+ "id": "tests",
457
+ "status": "UNKNOWN",
458
+ "severity": "medium",
459
+ "claim": "the project test suite passes",
460
+ "evidence": [{"code": "test-runner-missing", "path": ".",
461
+ "detail": "no whitelisted test runner was detected"}],
462
+ "next_step": "declare a test script or add test_*.py files",
463
+ })
464
+ else:
465
+ try:
466
+ from dev_engine import run_checks
467
+ result = run_checks(kind, str(root), timeout=timeout,
468
+ allow_execute=True)
469
+ output = result.get("output") or ""
470
+ checks.append({
471
+ "id": "tests",
472
+ "status": "PASS" if result.get("passed") else "FAIL",
473
+ "severity": "high",
474
+ "claim": "the project test suite passes",
475
+ "evidence": [{"code": "test-run", "path": ".",
476
+ "detail": result.get("summary") or "test run finished"}],
477
+ "next_step": None if result.get("passed")
478
+ else "fix the failing tests and rerun self_test",
479
+ "command": {
480
+ "kind": kind,
481
+ "cwd": ".",
482
+ "exit_code": result.get("exit_code"),
483
+ "output_hash": hashlib.sha256(
484
+ output.encode("utf-8")
485
+ ).hexdigest(),
486
+ "timed_out": bool(result.get("timed_out")),
487
+ },
488
+ })
489
+ except Exception as exc: # noqa: BLE001
490
+ checks.append({
491
+ "id": "tests",
492
+ "status": "FAIL",
493
+ "severity": "high",
494
+ "claim": "the project test suite passes",
495
+ "evidence": [{"code": "test-run-error", "path": ".",
496
+ "detail": str(exc)}],
497
+ "next_step": "fix the local test runner",
498
+ })
499
+ else:
500
+ unverified.append({
501
+ "level": "L2",
502
+ "claim": "the project test suite passes",
503
+ "status": "UNVERIFIED",
504
+ "reason": "allow_execute=false",
505
+ "required": False,
506
+ "next_step": "rerun self_test with allow_execute=true",
507
+ })
508
+
509
+ failed = any(item["status"] == "FAIL" for item in checks)
510
+ unknown = any(item["status"] == "UNKNOWN" for item in checks)
511
+ if failed:
512
+ status = "FAIL"
513
+ elif unknown:
514
+ status = "UNKNOWN"
515
+ else:
516
+ status = "PASS"
517
+ counts = {
518
+ "passed": sum(1 for item in checks if item["status"] == "PASS"),
519
+ "failed": sum(1 for item in checks if item["status"] == "FAIL"),
520
+ "unknown": sum(1 for item in checks if item["status"] == "UNKNOWN"),
521
+ "unverified": len(unverified),
522
+ }
523
+ evidence = []
524
+ for item in checks:
525
+ evidence.extend(item.get("evidence") or [])
526
+ return {
527
+ "status": status,
528
+ "root": str(root.resolve()),
529
+ "mode": mode,
530
+ "checks": checks,
531
+ "unverified_claims": unverified,
532
+ "summary": counts,
533
+ "evidence": evidence[:EVIDENCE_LIMIT],
534
+ }