@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.
@@ -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())