@yottameta/yotta-dev-mcp-plugin 0.0.0 → 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.
- package/.agents/plugins/marketplace.json +25 -0
- package/.claude-plugin/marketplace.json +25 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/mcp.json +12 -0
- package/package.json +13 -10
- package/plugin.json +20 -0
- package/skills/yotta-dev-mcp/LICENSE +21 -0
- package/skills/yotta-dev-mcp/NOTICE +7 -0
- package/skills/yotta-dev-mcp/SKILL.md +81 -0
- package/skills/yotta-dev-mcp/assets/banner.png +0 -0
- package/skills/yotta-dev-mcp/bin/yotta-dev-mcp.js +57 -0
- 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 +257 -0
- 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 +144 -0
- package/skills/yotta-dev-mcp/scripts/dev_contract.py +830 -0
- package/skills/yotta-dev-mcp/scripts/dev_engine.py +1138 -0
- package/skills/yotta-dev-mcp/scripts/dev_impact.py +556 -0
- package/skills/yotta-dev-mcp/scripts/dev_model.py +435 -0
- package/skills/yotta-dev-mcp/scripts/dev_rules.py +25 -0
- package/skills/yotta-dev-mcp/scripts/dev_selftest.py +534 -0
- package/skills/yotta-dev-mcp/scripts/dev_verify.py +450 -0
- package/skills/yotta-dev-mcp/scripts/yotta_dev_mcp.py +659 -0
- package/skills/yotta-dev-mcp/server.json +20 -0
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Deterministic development tools for yotta-dev-mcp.
|
|
4
|
+
|
|
5
|
+
The engine is intentionally small and self-contained: Python 3.8+ standard
|
|
6
|
+
library only, offline by default, deterministic output, read-only unless a
|
|
7
|
+
tool explicitly documents a write.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import dev_contract
|
|
21
|
+
from dev_adapters import run_adapter as _run_adapter_impl
|
|
22
|
+
from dev_architecture import architecture_review as _architecture_review_impl
|
|
23
|
+
from dev_common import (
|
|
24
|
+
ANSI_RE, ERROR_RE, MAX_FILE_BYTES, VERIFY_LEVELS, _iter_files, _json_safe,
|
|
25
|
+
_language, _read_text, _rel,
|
|
26
|
+
_frontmatter_name, _frontmatter_version, source_exts,
|
|
27
|
+
)
|
|
28
|
+
from dev_impact import impact_analysis as _impact_analysis_impl
|
|
29
|
+
from dev_verify import verify_change as _verify_change_impl
|
|
30
|
+
from dev_selftest import self_test
|
|
31
|
+
from dev_model import _repo_map_js, _repo_map_python, system_model as _system_model_impl
|
|
32
|
+
from dev_rules import REVIEW_RULES
|
|
33
|
+
|
|
34
|
+
VERSION = "0.2.0"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def repo_map(path, max_files=2000):
|
|
44
|
+
root = Path(path)
|
|
45
|
+
if not root.exists():
|
|
46
|
+
raise ValueError("路径不存在: %s" % path)
|
|
47
|
+
if not root.is_dir():
|
|
48
|
+
raise ValueError("repo_map 需要目录: %s" % path)
|
|
49
|
+
modules = []
|
|
50
|
+
imports = []
|
|
51
|
+
entrypoints = []
|
|
52
|
+
truncated = False
|
|
53
|
+
files = list(_iter_files(root, source_exts(), max_files=max_files + 1))
|
|
54
|
+
if len(files) > max_files:
|
|
55
|
+
truncated = True
|
|
56
|
+
files = files[:max_files]
|
|
57
|
+
for file_path in files:
|
|
58
|
+
rel = _rel(root, file_path)
|
|
59
|
+
try:
|
|
60
|
+
text = _read_text(file_path)
|
|
61
|
+
except (OSError, ValueError):
|
|
62
|
+
continue
|
|
63
|
+
language = _language(file_path)
|
|
64
|
+
modules.append({"path": rel, "language": language, "lines": len(text.splitlines())})
|
|
65
|
+
if file_path.suffix.lower() == ".py":
|
|
66
|
+
imports.extend(_repo_map_python(file_path, rel))
|
|
67
|
+
elif file_path.suffix.lower() in (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"):
|
|
68
|
+
imports.extend(_repo_map_js(file_path, rel))
|
|
69
|
+
if (
|
|
70
|
+
file_path.name in ("main.py", "cli.py", "app.py", "index.js", "index.ts")
|
|
71
|
+
or "if __name__ == '__main__'" in text
|
|
72
|
+
or 'if __name__ == "__main__"' in text
|
|
73
|
+
):
|
|
74
|
+
entrypoints.append(rel)
|
|
75
|
+
modules.sort(key=lambda item: item["path"])
|
|
76
|
+
imports.sort(key=lambda item: (item["source"], item["line"], item["target"]))
|
|
77
|
+
entrypoints = sorted(set(entrypoints))
|
|
78
|
+
return {
|
|
79
|
+
"root": str(root.resolve()),
|
|
80
|
+
"modules": modules,
|
|
81
|
+
"imports": imports,
|
|
82
|
+
"entrypoints": entrypoints,
|
|
83
|
+
"truncated": truncated,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _classify_match(line, query):
|
|
88
|
+
escaped = re.escape(query)
|
|
89
|
+
if re.search(r"^\s*(?:async\s+)?def\s+%s\b" % escaped, line):
|
|
90
|
+
return "definition"
|
|
91
|
+
if re.search(r"^\s*class\s+%s\b" % escaped, line):
|
|
92
|
+
return "definition"
|
|
93
|
+
if re.search(r"^\s*(?:export\s+)?(?:async\s+)?function\s+%s\b" % escaped, line):
|
|
94
|
+
return "definition"
|
|
95
|
+
if re.search(r"^\s*(?:export\s+)?(?:const|let|var)\s+%s\b" % escaped, line):
|
|
96
|
+
return "definition"
|
|
97
|
+
return "reference"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_code(path, query, extensions=None, max_results=100, context_lines=0):
|
|
101
|
+
root = Path(path)
|
|
102
|
+
if not root.exists():
|
|
103
|
+
raise ValueError("路径不存在: %s" % path)
|
|
104
|
+
if not query:
|
|
105
|
+
raise ValueError("find_code 需要 query")
|
|
106
|
+
if max_results < 1:
|
|
107
|
+
raise ValueError("max_results 必须大于 0")
|
|
108
|
+
context_lines = max(0, min(int(context_lines), 5))
|
|
109
|
+
matches = []
|
|
110
|
+
truncated = False
|
|
111
|
+
for file_path in _iter_files(root if root.is_dir() else root.parent, extensions=extensions):
|
|
112
|
+
if root.is_file() and file_path.resolve() != root.resolve():
|
|
113
|
+
continue
|
|
114
|
+
try:
|
|
115
|
+
lines = _read_text(file_path).splitlines()
|
|
116
|
+
except (OSError, ValueError):
|
|
117
|
+
continue
|
|
118
|
+
for index, line in enumerate(lines):
|
|
119
|
+
if query not in line:
|
|
120
|
+
continue
|
|
121
|
+
if len(matches) >= max_results:
|
|
122
|
+
truncated = True
|
|
123
|
+
break
|
|
124
|
+
start = max(0, index - context_lines)
|
|
125
|
+
end = min(len(lines), index + context_lines + 1)
|
|
126
|
+
matches.append({
|
|
127
|
+
"path": _rel(root if root.is_dir() else root.parent, file_path),
|
|
128
|
+
"line": index + 1,
|
|
129
|
+
"kind": _classify_match(line, query),
|
|
130
|
+
"text": line.strip()[:240],
|
|
131
|
+
"context": lines[start:end] if context_lines else [],
|
|
132
|
+
})
|
|
133
|
+
if truncated:
|
|
134
|
+
break
|
|
135
|
+
return {"query": query, "matches": matches, "truncated": truncated}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _dedupe_lines(lines):
|
|
139
|
+
output = []
|
|
140
|
+
index = 0
|
|
141
|
+
while index < len(lines):
|
|
142
|
+
line = lines[index]
|
|
143
|
+
count = 1
|
|
144
|
+
while index + count < len(lines) and lines[index + count] == line:
|
|
145
|
+
count += 1
|
|
146
|
+
output.append(line + (" [x%d]" % count if count > 1 else ""))
|
|
147
|
+
index += count
|
|
148
|
+
return output
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _fit_text(lines, max_chars):
|
|
152
|
+
text = "\n".join(lines)
|
|
153
|
+
if len(text) <= max_chars:
|
|
154
|
+
return text
|
|
155
|
+
if max_chars < 20:
|
|
156
|
+
return text[:max_chars]
|
|
157
|
+
output = []
|
|
158
|
+
for line in lines:
|
|
159
|
+
candidate = "\n".join(output + [line])
|
|
160
|
+
if len(candidate) > max_chars - 20:
|
|
161
|
+
break
|
|
162
|
+
output.append(line)
|
|
163
|
+
if output and output[-1] != "... [truncated]":
|
|
164
|
+
output.append("... [truncated]")
|
|
165
|
+
return "\n".join(output)[:max_chars]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def compress_output(text=None, file=None, max_chars=4000, head_lines=40, tail_lines=40):
|
|
169
|
+
if file and text is None:
|
|
170
|
+
text = _read_text(file)
|
|
171
|
+
if text is None:
|
|
172
|
+
raise ValueError("compress_output 需要 text 或 file")
|
|
173
|
+
text = ANSI_RE.sub("", str(text)).replace("\r\n", "\n").replace("\r", "\n")
|
|
174
|
+
raw_lines = [line.rstrip() for line in text.splitlines()]
|
|
175
|
+
deduped = _dedupe_lines(raw_lines)
|
|
176
|
+
priority = [line for line in deduped if ERROR_RE.search(line)]
|
|
177
|
+
head = deduped[:max(0, int(head_lines))]
|
|
178
|
+
tail = deduped[-max(0, int(tail_lines)):] if tail_lines else []
|
|
179
|
+
chosen = []
|
|
180
|
+
for line in priority + head + tail:
|
|
181
|
+
if line not in chosen:
|
|
182
|
+
chosen.append(line)
|
|
183
|
+
result_text = _fit_text(chosen, int(max_chars))
|
|
184
|
+
return {
|
|
185
|
+
"text": result_text,
|
|
186
|
+
"original_lines": len(raw_lines),
|
|
187
|
+
"kept_lines": len(result_text.splitlines()),
|
|
188
|
+
"error_lines": len(priority),
|
|
189
|
+
"truncated": len(result_text) < len("\n".join(deduped)),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _review_line(rel, line_no, line_text):
|
|
194
|
+
findings = []
|
|
195
|
+
for rule, severity, pattern, suggestion in REVIEW_RULES:
|
|
196
|
+
if pattern.search(line_text):
|
|
197
|
+
findings.append({
|
|
198
|
+
"path": rel,
|
|
199
|
+
"line": line_no,
|
|
200
|
+
"rule": rule,
|
|
201
|
+
"severity": severity,
|
|
202
|
+
"evidence": line_text.strip()[:240],
|
|
203
|
+
"suggestion": suggestion,
|
|
204
|
+
})
|
|
205
|
+
return findings
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def review_code(path=None, text=None, max_findings=200):
|
|
209
|
+
findings = []
|
|
210
|
+
if text is not None:
|
|
211
|
+
for line_no, line in enumerate(str(text).splitlines(), 1):
|
|
212
|
+
findings.extend(_review_line("<text>", line_no, line))
|
|
213
|
+
else:
|
|
214
|
+
if not path:
|
|
215
|
+
raise ValueError("review_code 需要 path 或 text")
|
|
216
|
+
root = Path(path)
|
|
217
|
+
if not root.exists():
|
|
218
|
+
raise ValueError("路径不存在: %s" % path)
|
|
219
|
+
files = [root] if root.is_file() else list(_iter_files(root, extensions=source_exts()))
|
|
220
|
+
base = root.parent if root.is_file() else root
|
|
221
|
+
for file_path in files:
|
|
222
|
+
try:
|
|
223
|
+
lines = _read_text(file_path).splitlines()
|
|
224
|
+
except (OSError, ValueError):
|
|
225
|
+
continue
|
|
226
|
+
rel = _rel(base, file_path)
|
|
227
|
+
for line_no, line in enumerate(lines, 1):
|
|
228
|
+
findings.extend(_review_line(rel, line_no, line))
|
|
229
|
+
findings.sort(key=lambda item: (item["path"], item["line"], item["rule"]))
|
|
230
|
+
truncated = len(findings) > max_findings
|
|
231
|
+
return {"findings": findings[:max_findings], "truncated": truncated}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parse_added_lines(diff_text):
|
|
235
|
+
current_file = None
|
|
236
|
+
line_no = 0
|
|
237
|
+
for raw in diff_text.splitlines():
|
|
238
|
+
if raw.startswith("+++ "):
|
|
239
|
+
current_file = raw[4:].strip()
|
|
240
|
+
if current_file.startswith("b/"):
|
|
241
|
+
current_file = current_file[2:]
|
|
242
|
+
continue
|
|
243
|
+
match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", raw)
|
|
244
|
+
if match:
|
|
245
|
+
line_no = int(match.group(1))
|
|
246
|
+
continue
|
|
247
|
+
if current_file and raw.startswith("+") and not raw.startswith("+++"):
|
|
248
|
+
yield current_file, line_no, raw[1:]
|
|
249
|
+
line_no += 1
|
|
250
|
+
elif current_file and not raw.startswith("-") and not raw.startswith("\\"):
|
|
251
|
+
line_no += 1
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def review_diff(diff_text=None, path=None, base=None, max_findings=200):
|
|
255
|
+
if diff_text is None:
|
|
256
|
+
if not path:
|
|
257
|
+
raise ValueError("review_diff 需要 diff_text 或 path")
|
|
258
|
+
command = ["git", "-C", str(path), "diff", "--no-ext-diff", "--unified=0"]
|
|
259
|
+
if base:
|
|
260
|
+
command.append(str(base))
|
|
261
|
+
proc = subprocess.run(command, capture_output=True, text=True)
|
|
262
|
+
if proc.returncode != 0:
|
|
263
|
+
raise ValueError("git diff 失败: %s" % (proc.stderr.strip() or proc.stdout.strip()))
|
|
264
|
+
diff_text = proc.stdout
|
|
265
|
+
findings = []
|
|
266
|
+
files = []
|
|
267
|
+
for rel, line_no, line in _parse_added_lines(diff_text):
|
|
268
|
+
if rel not in files:
|
|
269
|
+
files.append(rel)
|
|
270
|
+
findings.extend(_review_line(rel, line_no, line))
|
|
271
|
+
findings.sort(key=lambda item: (item["path"], item["line"], item["rule"]))
|
|
272
|
+
truncated = len(findings) > max_findings
|
|
273
|
+
return {"files": files, "findings": findings[:max_findings], "truncated": truncated}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _default_skill_dirs():
|
|
277
|
+
home = Path.home()
|
|
278
|
+
candidates = [
|
|
279
|
+
home / ".codex" / "skills",
|
|
280
|
+
home / ".claude" / "skills",
|
|
281
|
+
home / ".cursor" / "skills",
|
|
282
|
+
home / ".config" / "opencode" / "skills",
|
|
283
|
+
]
|
|
284
|
+
codex_home = os.environ.get("CODEX_HOME")
|
|
285
|
+
if codex_home:
|
|
286
|
+
candidates.insert(0, Path(codex_home) / "skills")
|
|
287
|
+
claude_home = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
288
|
+
if claude_home:
|
|
289
|
+
candidates.insert(0, Path(claude_home) / "skills")
|
|
290
|
+
xdg_home = os.environ.get("XDG_CONFIG_HOME")
|
|
291
|
+
if xdg_home:
|
|
292
|
+
candidates.insert(0, Path(xdg_home) / "opencode" / "skills")
|
|
293
|
+
return candidates
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _default_config_paths():
|
|
297
|
+
home = Path.home()
|
|
298
|
+
return [
|
|
299
|
+
home / ".codex" / "config.json",
|
|
300
|
+
home / ".codex" / "mcp.json",
|
|
301
|
+
home / ".config" / "opencode" / "opencode.json",
|
|
302
|
+
home / ".claude" / "settings.json",
|
|
303
|
+
home / ".cursor" / "mcp.json",
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def mcp_doctor(skills_dirs=None, config_paths=None):
|
|
308
|
+
skills = []
|
|
309
|
+
issues = []
|
|
310
|
+
for directory in (skills_dirs or _default_skill_dirs()):
|
|
311
|
+
root = Path(directory)
|
|
312
|
+
if not root.is_dir():
|
|
313
|
+
continue
|
|
314
|
+
for child in sorted(root.iterdir(), key=lambda item: item.name):
|
|
315
|
+
skill_file = child / "SKILL.md"
|
|
316
|
+
if not child.is_dir() or not skill_file.is_file():
|
|
317
|
+
continue
|
|
318
|
+
try:
|
|
319
|
+
text = _read_text(skill_file)
|
|
320
|
+
except (OSError, ValueError) as exc:
|
|
321
|
+
issues.append("%s: %s" % (skill_file, exc))
|
|
322
|
+
continue
|
|
323
|
+
skills.append({
|
|
324
|
+
"name": _frontmatter_name(text) or child.name,
|
|
325
|
+
"version": _frontmatter_version(text),
|
|
326
|
+
"path": str(skill_file),
|
|
327
|
+
})
|
|
328
|
+
mcp_configs = []
|
|
329
|
+
for config_path in (config_paths or _default_config_paths()):
|
|
330
|
+
path = Path(config_path)
|
|
331
|
+
if not path.is_file():
|
|
332
|
+
continue
|
|
333
|
+
try:
|
|
334
|
+
payload = json.loads(_read_text(path))
|
|
335
|
+
except Exception as exc: # noqa: BLE001
|
|
336
|
+
issues.append("%s: %s" % (path, exc))
|
|
337
|
+
continue
|
|
338
|
+
servers = payload.get("mcpServers") if isinstance(payload, dict) else None
|
|
339
|
+
mcp_configs.append({
|
|
340
|
+
"path": str(path),
|
|
341
|
+
"servers": sorted(servers.keys()) if isinstance(servers, dict) else [],
|
|
342
|
+
})
|
|
343
|
+
skills.sort(key=lambda item: (item["name"], item["path"]))
|
|
344
|
+
mcp_configs.sort(key=lambda item: item["path"])
|
|
345
|
+
return {
|
|
346
|
+
"skills": skills,
|
|
347
|
+
"mcp_configs": mcp_configs,
|
|
348
|
+
"issues": issues,
|
|
349
|
+
"checked_skills": len(skills),
|
|
350
|
+
"checked_configs": len(mcp_configs),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
SECRET_KEY_RE = re.compile(
|
|
355
|
+
r"""(?i)\b(api[_-]?key|access[_-]?key|secret|token|password|passwd|pwd)\b\s*[:=]\s*["']?([^"'\s#]{8,})"""
|
|
356
|
+
)
|
|
357
|
+
AWS_KEY_RE = re.compile(r"\bAKIA[0-9A-Z]{16}\b")
|
|
358
|
+
PRIVATE_KEY_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")
|
|
359
|
+
HIGH_ENTROPY_RE = re.compile(r"[A-Za-z0-9_+/=\-]{32,}")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _entropy(value):
|
|
363
|
+
if not value:
|
|
364
|
+
return 0.0
|
|
365
|
+
counts = {}
|
|
366
|
+
for char in value:
|
|
367
|
+
counts[char] = counts.get(char, 0) + 1
|
|
368
|
+
length = float(len(value))
|
|
369
|
+
return -sum((count / length) * math.log(count / length, 2) for count in counts.values())
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _redact(value):
|
|
373
|
+
if len(value) <= 8:
|
|
374
|
+
return "[REDACTED]"
|
|
375
|
+
return value[:4] + "...[REDACTED]"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def scan_secrets(path=None, text=None, max_findings=200, include_git_history=False):
|
|
379
|
+
entries = []
|
|
380
|
+
if text is not None:
|
|
381
|
+
entries.append(("<text>", str(text)))
|
|
382
|
+
else:
|
|
383
|
+
if not path:
|
|
384
|
+
raise ValueError("scan_secrets 需要 path 或 text")
|
|
385
|
+
root = Path(path)
|
|
386
|
+
if not root.exists():
|
|
387
|
+
raise ValueError("路径不存在: %s" % path)
|
|
388
|
+
files = [root] if root.is_file() else list(_iter_files(root, all_files=True))
|
|
389
|
+
base = root.parent if root.is_file() else root
|
|
390
|
+
for file_path in files:
|
|
391
|
+
try:
|
|
392
|
+
entries.append((_rel(base, file_path), _read_text(file_path)))
|
|
393
|
+
except (OSError, ValueError):
|
|
394
|
+
continue
|
|
395
|
+
if include_git_history:
|
|
396
|
+
entries.append(("git-history", _git_history_text(root)))
|
|
397
|
+
findings = []
|
|
398
|
+
for rel, content in entries:
|
|
399
|
+
for line_no, line in enumerate(content.splitlines(), 1):
|
|
400
|
+
if PRIVATE_KEY_RE.search(line):
|
|
401
|
+
findings.append({
|
|
402
|
+
"path": rel, "line": line_no, "rule": "private-key",
|
|
403
|
+
"severity": "critical", "evidence": "[REDACTED private key marker]",
|
|
404
|
+
"suggestion": "Remove the private key from source control and rotate it.",
|
|
405
|
+
})
|
|
406
|
+
for match in AWS_KEY_RE.finditer(line):
|
|
407
|
+
findings.append({
|
|
408
|
+
"path": rel, "line": line_no, "rule": "aws-access-key",
|
|
409
|
+
"severity": "critical", "evidence": _redact(match.group(0)),
|
|
410
|
+
"suggestion": "Rotate the key and move it to a secret manager.",
|
|
411
|
+
})
|
|
412
|
+
for match in SECRET_KEY_RE.finditer(line):
|
|
413
|
+
name = match.group(1).lower()
|
|
414
|
+
rule = "api-key" if "api" in name or "access" in name else (
|
|
415
|
+
"token" if "token" in name else "credential"
|
|
416
|
+
)
|
|
417
|
+
findings.append({
|
|
418
|
+
"path": rel, "line": line_no, "rule": rule,
|
|
419
|
+
"severity": "high", "evidence": "%s=%s" % (match.group(1), _redact(match.group(2))),
|
|
420
|
+
"suggestion": "Remove the literal credential and load it from the environment or a secret store.",
|
|
421
|
+
})
|
|
422
|
+
for match in HIGH_ENTROPY_RE.finditer(line):
|
|
423
|
+
token = match.group(0)
|
|
424
|
+
if _entropy(token) >= 4.0 and not PRIVATE_KEY_RE.search(line):
|
|
425
|
+
findings.append({
|
|
426
|
+
"path": rel, "line": line_no, "rule": "high-entropy-token",
|
|
427
|
+
"severity": "medium", "evidence": _redact(token),
|
|
428
|
+
"suggestion": "Verify whether this is a credential; if so, remove and rotate it.",
|
|
429
|
+
})
|
|
430
|
+
unique = {}
|
|
431
|
+
for item in findings:
|
|
432
|
+
key = (item["path"], item["line"], item["rule"], item["evidence"])
|
|
433
|
+
unique[key] = item
|
|
434
|
+
ordered = sorted(unique.values(), key=lambda item: (item["path"], item["line"], item["rule"], item["evidence"]))
|
|
435
|
+
return {"findings": ordered[:max_findings], "truncated": len(ordered) > max_findings}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _git_history_text(root):
|
|
439
|
+
"""Return bounded git diff text for secret scanning; never fail the scan."""
|
|
440
|
+
try:
|
|
441
|
+
process = subprocess.run(
|
|
442
|
+
["git", "-C", str(root), "log", "-p", "--all", "--no-ext-diff", "--max-count=50"],
|
|
443
|
+
capture_output=True, text=True, timeout=30,
|
|
444
|
+
)
|
|
445
|
+
if process.returncode != 0:
|
|
446
|
+
return ""
|
|
447
|
+
return process.stdout[:MAX_FILE_BYTES]
|
|
448
|
+
except Exception: # noqa: BLE001
|
|
449
|
+
return ""
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
DEPENDENCY_LOCKFILES = {
|
|
453
|
+
"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
|
|
454
|
+
"bun.lock", "poetry.lock", "uv.lock", "Pipfile.lock",
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _dependency_spec_issue(spec):
|
|
459
|
+
value = str(spec).strip()
|
|
460
|
+
if value in ("", "*", "latest") or value.startswith((">=", ">", "http://", "git+http://")):
|
|
461
|
+
return "unpinned-dependency"
|
|
462
|
+
if value.startswith(("file:", "link:")):
|
|
463
|
+
return "local-dependency"
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
POPULAR_PACKAGES = (
|
|
468
|
+
"requests", "numpy", "pytest", "lodash", "express", "react", "vue",
|
|
469
|
+
"typescript", "fastapi", "pydantic", "openai", "axios",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _edit_distance_one(left, right):
|
|
474
|
+
if abs(len(left) - len(right)) > 1:
|
|
475
|
+
return False
|
|
476
|
+
if left == right:
|
|
477
|
+
return False
|
|
478
|
+
if len(left) == len(right):
|
|
479
|
+
return sum(1 for a, b in zip(left, right) if a != b) == 1
|
|
480
|
+
short, long = (left, right) if len(left) < len(right) else (right, left)
|
|
481
|
+
index = 0
|
|
482
|
+
skipped = False
|
|
483
|
+
for char in long:
|
|
484
|
+
if index < len(short) and char == short[index]:
|
|
485
|
+
index += 1
|
|
486
|
+
elif skipped:
|
|
487
|
+
return False
|
|
488
|
+
else:
|
|
489
|
+
skipped = True
|
|
490
|
+
return True
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _typosquat_suspicion(name):
|
|
494
|
+
low = str(name).lower()
|
|
495
|
+
return any(_edit_distance_one(low, known) for known in POPULAR_PACKAGES)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def scan_dependencies(path):
|
|
499
|
+
root = Path(path)
|
|
500
|
+
if not root.exists():
|
|
501
|
+
raise ValueError("路径不存在: %s" % path)
|
|
502
|
+
if not root.is_dir():
|
|
503
|
+
raise ValueError("scan_dependencies 需要目录: %s" % path)
|
|
504
|
+
manifests = []
|
|
505
|
+
lockfiles = []
|
|
506
|
+
issues = []
|
|
507
|
+
for file_path in _iter_files(root, extensions={".json", ".txt", ".toml", ".lock"}, max_files=500):
|
|
508
|
+
rel = _rel(root, file_path)
|
|
509
|
+
if file_path.name in DEPENDENCY_LOCKFILES:
|
|
510
|
+
lockfiles.append(rel)
|
|
511
|
+
package_file = root / "package.json"
|
|
512
|
+
if package_file.is_file():
|
|
513
|
+
manifests.append("package.json")
|
|
514
|
+
try:
|
|
515
|
+
package = json.loads(_read_text(package_file))
|
|
516
|
+
except Exception as exc: # noqa: BLE001
|
|
517
|
+
issues.append({"code": "invalid-manifest", "severity": "high",
|
|
518
|
+
"manifest": "package.json", "message": str(exc)})
|
|
519
|
+
package = {}
|
|
520
|
+
groups = ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies")
|
|
521
|
+
has_deps = False
|
|
522
|
+
for group in groups:
|
|
523
|
+
dependencies = package.get(group) if isinstance(package, dict) else None
|
|
524
|
+
if not isinstance(dependencies, dict):
|
|
525
|
+
continue
|
|
526
|
+
has_deps = has_deps or bool(dependencies)
|
|
527
|
+
for name, spec in sorted(dependencies.items()):
|
|
528
|
+
code = _dependency_spec_issue(spec)
|
|
529
|
+
if code:
|
|
530
|
+
issues.append({
|
|
531
|
+
"code": code, "severity": "medium", "manifest": "package.json",
|
|
532
|
+
"dependency": name, "message": "%s uses %s" % (name, spec),
|
|
533
|
+
})
|
|
534
|
+
if _typosquat_suspicion(name):
|
|
535
|
+
issues.append({
|
|
536
|
+
"code": "typosquat-suspicion", "severity": "medium",
|
|
537
|
+
"manifest": "package.json", "dependency": name,
|
|
538
|
+
"message": "%s is one edit away from a popular package; verify the name" % name,
|
|
539
|
+
"requires_manual_review": True,
|
|
540
|
+
})
|
|
541
|
+
if has_deps and not lockfiles:
|
|
542
|
+
issues.append({
|
|
543
|
+
"code": "missing-lockfile", "severity": "medium", "manifest": "package.json",
|
|
544
|
+
"message": "Dependencies exist but no lockfile was found.",
|
|
545
|
+
})
|
|
546
|
+
for requirement in sorted(root.glob("requirements*.txt")):
|
|
547
|
+
manifests.append(_rel(root, requirement))
|
|
548
|
+
for line_no, line in enumerate(_read_text(requirement).splitlines(), 1):
|
|
549
|
+
stripped = line.strip()
|
|
550
|
+
if not stripped or stripped.startswith("#"):
|
|
551
|
+
continue
|
|
552
|
+
if stripped.startswith(("git+http://", "http://")):
|
|
553
|
+
issues.append({"code": "insecure-source", "severity": "high",
|
|
554
|
+
"manifest": _rel(root, requirement), "line": line_no,
|
|
555
|
+
"message": stripped})
|
|
556
|
+
elif "@" in stripped and "==" not in stripped and ">=" not in stripped:
|
|
557
|
+
issues.append({"code": "unpinned-dependency", "severity": "medium",
|
|
558
|
+
"manifest": _rel(root, requirement), "line": line_no,
|
|
559
|
+
"message": stripped})
|
|
560
|
+
pyproject = root / "pyproject.toml"
|
|
561
|
+
if pyproject.is_file():
|
|
562
|
+
manifests.append("pyproject.toml")
|
|
563
|
+
text = _read_text(pyproject)
|
|
564
|
+
if "dependencies" in text and not (root / "poetry.lock").is_file() and not (root / "uv.lock").is_file():
|
|
565
|
+
issues.append({
|
|
566
|
+
"code": "missing-lockfile", "severity": "medium",
|
|
567
|
+
"manifest": "pyproject.toml",
|
|
568
|
+
"message": "Python dependencies exist but no poetry.lock / uv.lock was found.",
|
|
569
|
+
})
|
|
570
|
+
pipfile = root / "Pipfile"
|
|
571
|
+
if pipfile.is_file():
|
|
572
|
+
manifests.append("Pipfile")
|
|
573
|
+
if not (root / "Pipfile.lock").is_file():
|
|
574
|
+
issues.append({
|
|
575
|
+
"code": "missing-lockfile", "severity": "medium",
|
|
576
|
+
"manifest": "Pipfile",
|
|
577
|
+
"message": "Pipfile exists but Pipfile.lock was not found.",
|
|
578
|
+
})
|
|
579
|
+
issues.sort(key=lambda item: (item.get("manifest", ""), item.get("line", 0), item["code"], item.get("dependency", "")))
|
|
580
|
+
return {
|
|
581
|
+
"manifests": sorted(manifests),
|
|
582
|
+
"lockfiles": sorted(lockfiles),
|
|
583
|
+
"issues": issues,
|
|
584
|
+
"checked_manifests": len(manifests),
|
|
585
|
+
"checked_lockfiles": len(lockfiles),
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def check_publish_readiness(path):
|
|
590
|
+
root = Path(path)
|
|
591
|
+
if not root.exists() or not root.is_dir():
|
|
592
|
+
raise ValueError("check_publish_readiness 需要目录: %s" % path)
|
|
593
|
+
required = ["package.json", "SKILL.md", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
594
|
+
files = {}
|
|
595
|
+
issues = []
|
|
596
|
+
for name in required:
|
|
597
|
+
file_path = root / name
|
|
598
|
+
files[name] = file_path.is_file()
|
|
599
|
+
if not file_path.is_file():
|
|
600
|
+
issues.append({"code": "missing-file", "severity": "high",
|
|
601
|
+
"message": "Missing required file: %s" % name})
|
|
602
|
+
versions = {}
|
|
603
|
+
package = {}
|
|
604
|
+
package_path = root / "package.json"
|
|
605
|
+
if package_path.is_file():
|
|
606
|
+
try:
|
|
607
|
+
package = json.loads(_read_text(package_path))
|
|
608
|
+
except Exception as exc: # noqa: BLE001
|
|
609
|
+
issues.append({"code": "invalid-package-json", "severity": "high", "message": str(exc)})
|
|
610
|
+
package = {}
|
|
611
|
+
versions["package.json"] = package.get("version")
|
|
612
|
+
skill_path = root / "SKILL.md"
|
|
613
|
+
if skill_path.is_file():
|
|
614
|
+
versions["SKILL.md"] = _frontmatter_version(_read_text(skill_path))
|
|
615
|
+
changelog_path = root / "CHANGELOG.md"
|
|
616
|
+
if changelog_path.is_file():
|
|
617
|
+
match = re.search(r"(?m)^##\s+v?(\d+\.\d+\.\d+)", _read_text(changelog_path))
|
|
618
|
+
versions["CHANGELOG.md"] = match.group(1) if match else None
|
|
619
|
+
script_versions = []
|
|
620
|
+
for script in sorted(root.glob("scripts/*.py")):
|
|
621
|
+
try:
|
|
622
|
+
match = re.search(r'(?m)^VERSION\s*=\s*["\']([^"\']+)["\']', _read_text(script))
|
|
623
|
+
except (OSError, ValueError):
|
|
624
|
+
continue
|
|
625
|
+
if match:
|
|
626
|
+
script_versions.append(match.group(1))
|
|
627
|
+
if script_versions:
|
|
628
|
+
versions["engine"] = script_versions[0]
|
|
629
|
+
values = [value for value in versions.values() if value]
|
|
630
|
+
if len(set(values)) > 1:
|
|
631
|
+
issues.append({
|
|
632
|
+
"code": "version-mismatch", "severity": "high",
|
|
633
|
+
"message": "Version mismatch: %s" % json.dumps(versions, ensure_ascii=False, sort_keys=True),
|
|
634
|
+
})
|
|
635
|
+
repository = package.get("repository") if isinstance(package, dict) else None
|
|
636
|
+
repository_url = repository.get("url") if isinstance(repository, dict) else repository
|
|
637
|
+
if not repository_url:
|
|
638
|
+
issues.append({"code": "missing-repository", "severity": "medium",
|
|
639
|
+
"message": "package.json repository.url is missing."})
|
|
640
|
+
publish_config = package.get("publishConfig") if isinstance(package, dict) else None
|
|
641
|
+
if not isinstance(publish_config, dict) or publish_config.get("access") != "public":
|
|
642
|
+
issues.append({"code": "publish-access", "severity": "medium",
|
|
643
|
+
"message": "package.json publishConfig.access should be public."})
|
|
644
|
+
issues.sort(key=lambda item: (item["code"], item.get("message", "")))
|
|
645
|
+
return {
|
|
646
|
+
"ok": not any(item["severity"] == "high" for item in issues),
|
|
647
|
+
"root": str(root.resolve()),
|
|
648
|
+
"files": files,
|
|
649
|
+
"versions": versions,
|
|
650
|
+
"issues": issues,
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
CHECK_COMMANDS = {
|
|
655
|
+
"python-unittest": lambda: [sys.executable, "-m", "unittest", "discover", "-v"],
|
|
656
|
+
"pytest": lambda: [sys.executable, "-m", "pytest", "-q"],
|
|
657
|
+
"python-compile": lambda: [sys.executable, "-m", "compileall", "-q", "."],
|
|
658
|
+
"npm-test": lambda: ["npm", "test", "--silent"],
|
|
659
|
+
"npm-lint": lambda: ["npm", "run", "lint", "--silent"],
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def run_checks(kind, cwd, timeout=120, allow_execute=False):
|
|
664
|
+
if kind not in CHECK_COMMANDS:
|
|
665
|
+
raise ValueError("unsupported check kind: %s" % kind)
|
|
666
|
+
if not allow_execute:
|
|
667
|
+
raise PermissionError("run_checks is disabled by default; pass allow_execute=true explicitly")
|
|
668
|
+
root = Path(cwd)
|
|
669
|
+
if not root.is_dir():
|
|
670
|
+
raise ValueError("cwd is not a directory: %s" % cwd)
|
|
671
|
+
command = CHECK_COMMANDS[kind]()
|
|
672
|
+
if os.name == "nt" and command[0] == "npm":
|
|
673
|
+
command = ["cmd", "/c"] + command
|
|
674
|
+
try:
|
|
675
|
+
process = subprocess.run(
|
|
676
|
+
command, cwd=str(root), capture_output=True, text=True, timeout=timeout
|
|
677
|
+
)
|
|
678
|
+
output = (process.stdout or "") + (process.stderr or "")
|
|
679
|
+
result = compress_output(output, max_chars=4000)
|
|
680
|
+
summary_lines = [
|
|
681
|
+
line.strip() for line in output.splitlines()
|
|
682
|
+
if re.search(r"(?i)(ran \d+ test|ok\b|failed\b|error\b|\d+ passed)", line)
|
|
683
|
+
]
|
|
684
|
+
return {
|
|
685
|
+
"kind": kind,
|
|
686
|
+
"cwd": str(root.resolve()),
|
|
687
|
+
"exit_code": process.returncode,
|
|
688
|
+
"passed": process.returncode == 0,
|
|
689
|
+
"summary": "\n".join(summary_lines[:8]) or "no summary matched",
|
|
690
|
+
"output": result["text"],
|
|
691
|
+
"timed_out": False,
|
|
692
|
+
}
|
|
693
|
+
except subprocess.TimeoutExpired:
|
|
694
|
+
return {
|
|
695
|
+
"kind": kind, "cwd": str(root.resolve()), "exit_code": 124,
|
|
696
|
+
"passed": False, "summary": "timeout after %ss" % timeout,
|
|
697
|
+
"output": "", "timed_out": True,
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _license_text():
|
|
702
|
+
return (
|
|
703
|
+
"MIT License\n\nCopyright (c) 2026 YottaMeta\n\n"
|
|
704
|
+
"Permission is hereby granted, free of charge, to any person obtaining a copy\n"
|
|
705
|
+
"of this software and associated documentation files (the \"Software\"), to deal\n"
|
|
706
|
+
"in the Software without restriction, including without limitation the rights\n"
|
|
707
|
+
"to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n"
|
|
708
|
+
"copies of the Software, and to permit persons to whom the Software is\n"
|
|
709
|
+
"furnished to do so, subject to the following conditions:\n\n"
|
|
710
|
+
"The above copyright notice and this permission notice shall be included in all\n"
|
|
711
|
+
"copies or substantial portions of the Software.\n\n"
|
|
712
|
+
"THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n"
|
|
713
|
+
"IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n"
|
|
714
|
+
"FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n"
|
|
715
|
+
"AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n"
|
|
716
|
+
"LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n"
|
|
717
|
+
"OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n"
|
|
718
|
+
"SOFTWARE.\n"
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def scaffold_skill(name, output_dir, apply=False, description=None):
|
|
723
|
+
if not re.match(r"^[a-z0-9][a-z0-9-]*$", str(name)):
|
|
724
|
+
raise ValueError("skill name must match ^[a-z0-9][a-z0-9-]*$")
|
|
725
|
+
target = Path(output_dir) / name
|
|
726
|
+
description = description or "Deterministic local helper skill."
|
|
727
|
+
files = {
|
|
728
|
+
"SKILL.md": (
|
|
729
|
+
"---\nname: %s\ndescription: %s\nversion: 0.1.0\nlicense: MIT\n---\n\n"
|
|
730
|
+
"# %s\n\n%s\n" % (name, description, name, description)
|
|
731
|
+
),
|
|
732
|
+
"package.json": json.dumps({
|
|
733
|
+
"name": "@yottameta/%s" % name,
|
|
734
|
+
"version": "0.1.0",
|
|
735
|
+
"description": description,
|
|
736
|
+
"license": "MIT",
|
|
737
|
+
"repository": {"type": "git", "url": "git+https://github.com/YottaMeta/%s.git" % name},
|
|
738
|
+
"publishConfig": {"access": "public"},
|
|
739
|
+
"files": ["SKILL.md", "README.md", "scripts", "LICENSE", "NOTICE"],
|
|
740
|
+
}, ensure_ascii=False, indent=2) + "\n",
|
|
741
|
+
"README.md": "# %s\n\n%s\n" % (name, description),
|
|
742
|
+
"CHANGELOG.md": "# Changelog\n\n## v0.1.0 (2026-09-25)\n\n- Initial scaffold.\n",
|
|
743
|
+
"NOTICE": "# NOTICE\n\nGenerated by yotta-dev-mcp scaffold_skill.\n",
|
|
744
|
+
"LICENSE": _license_text(),
|
|
745
|
+
"scripts/%s.py" % name: (
|
|
746
|
+
"#!/usr/bin/env python3\n"
|
|
747
|
+
"# -*- coding: utf-8 -*-\n"
|
|
748
|
+
"\"\"\"%s.\"\"\"\n\n"
|
|
749
|
+
"def main():\n"
|
|
750
|
+
" return 0\n\n"
|
|
751
|
+
"if __name__ == '__main__':\n"
|
|
752
|
+
" raise SystemExit(main())\n" % description
|
|
753
|
+
),
|
|
754
|
+
}
|
|
755
|
+
if apply and target.exists() and any(target.iterdir()):
|
|
756
|
+
raise ValueError("target already exists and is not empty: %s" % target)
|
|
757
|
+
if apply:
|
|
758
|
+
for rel, content in files.items():
|
|
759
|
+
file_path = target / rel
|
|
760
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
761
|
+
file_path.write_text(content, encoding="utf-8")
|
|
762
|
+
return {
|
|
763
|
+
"name": name,
|
|
764
|
+
"target": str(target),
|
|
765
|
+
"applied": bool(apply),
|
|
766
|
+
"files": [{"path": rel, "bytes": len(content.encode("utf-8"))}
|
|
767
|
+
for rel, content in sorted(files.items())],
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _atomic_write(path, content):
|
|
772
|
+
path = Path(path)
|
|
773
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
774
|
+
backup = None
|
|
775
|
+
if path.exists():
|
|
776
|
+
backup = path.with_suffix(path.suffix + ".bak")
|
|
777
|
+
shutil.copy2(str(path), str(backup))
|
|
778
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
779
|
+
temporary.write_text(content, encoding="utf-8")
|
|
780
|
+
os.replace(str(temporary), str(path))
|
|
781
|
+
return str(backup) if backup else None
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def workflow_state(root, action="read", date=None, text=None, file=None, apply=False):
|
|
785
|
+
workflow = Path(root) / ".workflow"
|
|
786
|
+
files = ["STATE.md", "TASKS.md", "DECISIONS.md", "ROADMAP.md"]
|
|
787
|
+
if action == "read":
|
|
788
|
+
existing = [name for name in files if (workflow / name).is_file()]
|
|
789
|
+
missing = [name for name in files if name not in existing]
|
|
790
|
+
excerpts = {}
|
|
791
|
+
for name in existing:
|
|
792
|
+
try:
|
|
793
|
+
content = _read_text(workflow / name)
|
|
794
|
+
except (OSError, ValueError):
|
|
795
|
+
content = ""
|
|
796
|
+
excerpts[name] = content[:1200]
|
|
797
|
+
return {
|
|
798
|
+
"ok": not missing,
|
|
799
|
+
"workflow": str(workflow.resolve()),
|
|
800
|
+
"files": existing,
|
|
801
|
+
"missing": missing,
|
|
802
|
+
"excerpts": excerpts,
|
|
803
|
+
"applied": False,
|
|
804
|
+
}
|
|
805
|
+
if action == "append-log":
|
|
806
|
+
if not date or not re.match(r"^\d{4}-\d{2}-\d{2}$", str(date)):
|
|
807
|
+
raise ValueError("append-log requires date=YYYY-MM-DD")
|
|
808
|
+
target = workflow / "logs" / ("%s.md" % date)
|
|
809
|
+
elif action == "append-file":
|
|
810
|
+
if file not in files:
|
|
811
|
+
raise ValueError("append-file requires one of: %s" % ", ".join(files))
|
|
812
|
+
target = workflow / file
|
|
813
|
+
else:
|
|
814
|
+
raise ValueError("unsupported workflow action: %s" % action)
|
|
815
|
+
body = (text or "").rstrip() + "\n"
|
|
816
|
+
preview = body
|
|
817
|
+
if apply:
|
|
818
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
819
|
+
if target.exists():
|
|
820
|
+
existing = _read_text(target)
|
|
821
|
+
if existing and not existing.endswith("\n"):
|
|
822
|
+
existing += "\n"
|
|
823
|
+
_atomic_write(target, existing + "\n" + body)
|
|
824
|
+
else:
|
|
825
|
+
header = ""
|
|
826
|
+
if action == "append-log":
|
|
827
|
+
header = "# 流水日志 %s\n\n" % date
|
|
828
|
+
_atomic_write(target, header + body)
|
|
829
|
+
return {
|
|
830
|
+
"ok": True,
|
|
831
|
+
"action": action,
|
|
832
|
+
"target": str(target.resolve()),
|
|
833
|
+
"applied": bool(apply),
|
|
834
|
+
"preview": preview,
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def system_model(path, max_files=2000, contract_file=None):
|
|
941
|
+
"""Build the deterministic system model through the stable engine facade."""
|
|
942
|
+
return _system_model_impl(
|
|
943
|
+
path, max_files=max_files, contract_file=contract_file,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def architecture_review(path, max_files=2000, contract_file=None):
|
|
948
|
+
"""Review architecture through the stable engine facade."""
|
|
949
|
+
return _architecture_review_impl(
|
|
950
|
+
path, max_files=max_files, contract_file=contract_file,
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def impact_analysis(path, changed_files=None, diff=None, symbols=None, depth=3,
|
|
955
|
+
max_files=2000, contract_file=None):
|
|
956
|
+
"""Build the change impact cone through the stable engine facade."""
|
|
957
|
+
return _impact_analysis_impl(
|
|
958
|
+
path, changed_files=changed_files, diff=diff, symbols=symbols,
|
|
959
|
+
depth=depth, max_files=max_files, contract_file=contract_file,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def verify_change(path, changed_files=None, diff=None, symbols=None, depth=3,
|
|
964
|
+
levels=None, allow_execute=False, timeout=120,
|
|
965
|
+
max_files=2000, contract_file=None, policy_file=None):
|
|
966
|
+
"""Run the verification ladder through the stable engine facade."""
|
|
967
|
+
return _verify_change_impl(
|
|
968
|
+
path, changed_files=changed_files, diff=diff, symbols=symbols,
|
|
969
|
+
depth=depth, levels=levels, allow_execute=allow_execute, timeout=timeout,
|
|
970
|
+
max_files=max_files, contract_file=contract_file, policy_file=policy_file,
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def run_adapter(path, action="list", adapter=None, allow_execute=False, timeout=120,
|
|
975
|
+
max_chars=120000, token_budget=None, target="."):
|
|
976
|
+
"""Probe or explicitly run one optional external adapter.
|
|
977
|
+
|
|
978
|
+
The implementation lives in dev_adapters.py; this facade keeps the public
|
|
979
|
+
dispatch signature and fail-closed default visible to self_test.
|
|
980
|
+
"""
|
|
981
|
+
return _run_adapter_impl(
|
|
982
|
+
path, action=action, adapter=adapter, allow_execute=allow_execute,
|
|
983
|
+
timeout=timeout, max_chars=max_chars, token_budget=token_budget,
|
|
984
|
+
target=target,
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def dispatch(name, arguments):
|
|
989
|
+
handlers = {
|
|
990
|
+
"repo_map": repo_map,
|
|
991
|
+
"system_model": system_model,
|
|
992
|
+
"architecture_review": architecture_review,
|
|
993
|
+
"impact_analysis": impact_analysis,
|
|
994
|
+
"verify_change": verify_change,
|
|
995
|
+
"self_test": self_test,
|
|
996
|
+
"run_adapter": run_adapter,
|
|
997
|
+
"find_code": find_code,
|
|
998
|
+
"compress_output": compress_output,
|
|
999
|
+
"review_code": review_code,
|
|
1000
|
+
"review_diff": review_diff,
|
|
1001
|
+
"mcp_doctor": mcp_doctor,
|
|
1002
|
+
"scan_secrets": scan_secrets,
|
|
1003
|
+
"scan_dependencies": scan_dependencies,
|
|
1004
|
+
"check_publish_readiness": check_publish_readiness,
|
|
1005
|
+
"run_checks": run_checks,
|
|
1006
|
+
"scaffold_skill": scaffold_skill,
|
|
1007
|
+
"workflow_state": workflow_state,
|
|
1008
|
+
}
|
|
1009
|
+
if name not in handlers:
|
|
1010
|
+
raise ValueError("未知工具: %s" % name)
|
|
1011
|
+
return handlers[name](**(arguments or {}))
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def main():
|
|
1015
|
+
parser = argparse.ArgumentParser(description="Deterministic development tools for yotta-dev-mcp")
|
|
1016
|
+
parser.add_argument("--version", action="version", version=VERSION)
|
|
1017
|
+
sub = parser.add_subparsers(dest="command")
|
|
1018
|
+
repo = sub.add_parser("repo-map")
|
|
1019
|
+
repo.add_argument("path")
|
|
1020
|
+
model = sub.add_parser("system-model")
|
|
1021
|
+
model.add_argument("path")
|
|
1022
|
+
model.add_argument("--contract", help="contract path relative to the repository root")
|
|
1023
|
+
review = sub.add_parser("architecture-review")
|
|
1024
|
+
review.add_argument("path")
|
|
1025
|
+
review.add_argument("--contract", help="contract path relative to the repository root")
|
|
1026
|
+
impact = sub.add_parser("impact-analysis")
|
|
1027
|
+
impact.add_argument("path")
|
|
1028
|
+
impact.add_argument("--changed", action="append",
|
|
1029
|
+
help="repository-relative changed file (repeatable)")
|
|
1030
|
+
impact.add_argument("--diff-file", help="read a unified diff from this file")
|
|
1031
|
+
impact.add_argument("--symbol", action="append",
|
|
1032
|
+
help="target symbol whose definition site is the change (repeatable)")
|
|
1033
|
+
impact.add_argument("--depth", type=int, default=3,
|
|
1034
|
+
help="reverse-dependency depth, 1-10 (default 3)")
|
|
1035
|
+
impact.add_argument("--contract", help="contract path relative to the repository root")
|
|
1036
|
+
verify = sub.add_parser("verify-change")
|
|
1037
|
+
verify.add_argument("path")
|
|
1038
|
+
verify.add_argument("--changed", action="append",
|
|
1039
|
+
help="repository-relative changed file (repeatable)")
|
|
1040
|
+
verify.add_argument("--diff-file", help="read a unified diff from this file")
|
|
1041
|
+
verify.add_argument("--symbol", action="append",
|
|
1042
|
+
help="target symbol whose definition site is the change (repeatable)")
|
|
1043
|
+
verify.add_argument("--level", action="append", choices=VERIFY_LEVELS,
|
|
1044
|
+
help="additional verification level (repeatable)")
|
|
1045
|
+
verify.add_argument("--depth", type=int, default=3,
|
|
1046
|
+
help="reverse-dependency depth, 1-10 (default 3)")
|
|
1047
|
+
verify.add_argument("--contract", help="contract path relative to the repository root")
|
|
1048
|
+
verify.add_argument("--policy-file", help="verification policy path relative to the root")
|
|
1049
|
+
verify.add_argument("--allow-execute", action="store_true",
|
|
1050
|
+
help="run whitelisted L2-L4 policy checks")
|
|
1051
|
+
verify.add_argument("--timeout", type=int, default=120,
|
|
1052
|
+
help="upper bound for each check in seconds")
|
|
1053
|
+
self_test_parser = sub.add_parser("self-test")
|
|
1054
|
+
self_test_parser.add_argument("path")
|
|
1055
|
+
self_test_parser.add_argument("--mode", choices=("auto", "source", "installed"),
|
|
1056
|
+
default="auto")
|
|
1057
|
+
self_test_parser.add_argument("--allow-execute", action="store_true",
|
|
1058
|
+
help="run the target test suite")
|
|
1059
|
+
self_test_parser.add_argument("--timeout", type=int, default=120,
|
|
1060
|
+
help="test timeout in seconds")
|
|
1061
|
+
adapter = sub.add_parser("adapter")
|
|
1062
|
+
adapter.add_argument("path")
|
|
1063
|
+
adapter.add_argument("--action", choices=("list", "run"), default="list")
|
|
1064
|
+
adapter.add_argument("--adapter",
|
|
1065
|
+
choices=("import-linter", "dependency-cruiser", "repomix"))
|
|
1066
|
+
adapter.add_argument("--allow-execute", action="store_true",
|
|
1067
|
+
help="run the selected adapter; required for action=run")
|
|
1068
|
+
adapter.add_argument("--timeout", type=int, default=120,
|
|
1069
|
+
help="adapter timeout in seconds")
|
|
1070
|
+
adapter.add_argument("--max-chars", type=int, default=120000,
|
|
1071
|
+
help="Repomix output cap")
|
|
1072
|
+
adapter.add_argument("--token-budget", type=int,
|
|
1073
|
+
help="optional Repomix token budget")
|
|
1074
|
+
adapter.add_argument("--target", default=".",
|
|
1075
|
+
help="repository-relative adapter target, default .")
|
|
1076
|
+
find = sub.add_parser("find-code")
|
|
1077
|
+
find.add_argument("path")
|
|
1078
|
+
find.add_argument("query")
|
|
1079
|
+
compress = sub.add_parser("compress-output")
|
|
1080
|
+
compress.add_argument("file")
|
|
1081
|
+
review = sub.add_parser("review-code")
|
|
1082
|
+
review.add_argument("path")
|
|
1083
|
+
doctor = sub.add_parser("mcp-doctor")
|
|
1084
|
+
doctor.add_argument("--skills-dir", action="append")
|
|
1085
|
+
doctor.add_argument("--config", action="append")
|
|
1086
|
+
args = parser.parse_args()
|
|
1087
|
+
if args.command == "repo-map":
|
|
1088
|
+
result = repo_map(args.path)
|
|
1089
|
+
elif args.command == "system-model":
|
|
1090
|
+
result = system_model(args.path, contract_file=args.contract)
|
|
1091
|
+
elif args.command == "architecture-review":
|
|
1092
|
+
result = architecture_review(args.path, contract_file=args.contract)
|
|
1093
|
+
elif args.command == "impact-analysis":
|
|
1094
|
+
diff_text = None
|
|
1095
|
+
if args.diff_file:
|
|
1096
|
+
diff_text = Path(args.diff_file).read_text(encoding="utf-8")
|
|
1097
|
+
result = impact_analysis(args.path, changed_files=args.changed, diff=diff_text,
|
|
1098
|
+
symbols=args.symbol, depth=args.depth,
|
|
1099
|
+
contract_file=args.contract)
|
|
1100
|
+
elif args.command == "verify-change":
|
|
1101
|
+
diff_text = None
|
|
1102
|
+
if args.diff_file:
|
|
1103
|
+
diff_text = Path(args.diff_file).read_text(encoding="utf-8")
|
|
1104
|
+
result = verify_change(
|
|
1105
|
+
args.path, changed_files=args.changed, diff=diff_text,
|
|
1106
|
+
symbols=args.symbol, depth=args.depth, levels=args.level,
|
|
1107
|
+
allow_execute=args.allow_execute, timeout=args.timeout,
|
|
1108
|
+
contract_file=args.contract, policy_file=args.policy_file,
|
|
1109
|
+
)
|
|
1110
|
+
elif args.command == "self-test":
|
|
1111
|
+
result = self_test(
|
|
1112
|
+
args.path, mode=args.mode, allow_execute=args.allow_execute,
|
|
1113
|
+
timeout=args.timeout,
|
|
1114
|
+
)
|
|
1115
|
+
elif args.command == "adapter":
|
|
1116
|
+
result = run_adapter(
|
|
1117
|
+
args.path, action=args.action, adapter=args.adapter,
|
|
1118
|
+
allow_execute=args.allow_execute, timeout=args.timeout,
|
|
1119
|
+
max_chars=args.max_chars, token_budget=args.token_budget,
|
|
1120
|
+
target=args.target,
|
|
1121
|
+
)
|
|
1122
|
+
elif args.command == "find-code":
|
|
1123
|
+
result = find_code(args.path, args.query)
|
|
1124
|
+
elif args.command == "compress-output":
|
|
1125
|
+
result = compress_output(file=args.file)
|
|
1126
|
+
elif args.command == "review-code":
|
|
1127
|
+
result = review_code(args.path)
|
|
1128
|
+
elif args.command == "mcp-doctor":
|
|
1129
|
+
result = mcp_doctor(skills_dirs=args.skills_dir, config_paths=args.config)
|
|
1130
|
+
else:
|
|
1131
|
+
parser.print_help()
|
|
1132
|
+
return 2
|
|
1133
|
+
sys.stdout.write(json.dumps(_json_safe(result), ensure_ascii=False, indent=2) + "\n")
|
|
1134
|
+
return 0
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
if __name__ == "__main__":
|
|
1138
|
+
sys.exit(main())
|