@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.
- package/.agents/plugins/marketplace.json +3 -3
- package/.claude-plugin/marketplace.json +3 -3
- package/package.json +1 -1
- package/plugin.json +2 -2
- package/skills/yotta-dev-mcp/SKILL.md +24 -9
- package/skills/yotta-dev-mcp/references/adapters.md +69 -0
- package/skills/yotta-dev-mcp/references/architecture-contract.md +256 -0
- package/skills/yotta-dev-mcp/references/tools.md +182 -3
- package/skills/yotta-dev-mcp/scripts/dev_adapters.py +609 -0
- package/skills/yotta-dev-mcp/scripts/dev_architecture.py +379 -0
- package/skills/yotta-dev-mcp/scripts/dev_common.py +155 -0
- package/skills/yotta-dev-mcp/scripts/dev_contract.py +830 -0
- package/skills/yotta-dev-mcp/scripts/dev_engine.py +328 -210
- package/skills/yotta-dev-mcp/scripts/dev_impact.py +556 -0
- package/skills/yotta-dev-mcp/scripts/dev_mcp_doctor.py +768 -0
- package/skills/yotta-dev-mcp/scripts/dev_model.py +447 -0
- package/skills/yotta-dev-mcp/scripts/dev_selftest.py +544 -0
- package/skills/yotta-dev-mcp/scripts/dev_verify.py +450 -0
- package/skills/yotta-dev-mcp/scripts/yotta_dev_mcp.py +246 -7
- package/skills/yotta-dev-mcp/server.json +3 -3
|
@@ -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
|
+
}
|