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