@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,609 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Optional external adapters for yotta-dev-mcp.
4
+
5
+ The core engine stays standard-library-only. This module only probes and
6
+ explicitly runs mature third-party tools that the user already installed.
7
+ It never installs packages, downloads files or accepts arbitrary argv.
8
+ """
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+
19
+ ADAPTER_SPECS = {
20
+ "import-linter": {
21
+ "package": "import-linter",
22
+ "license": "BSD-2-Clause",
23
+ "kind": "architecture",
24
+ "description": "Python import boundary and layered architecture contracts",
25
+ "executables": ("lint-imports",),
26
+ "config_names": (".importlinter",),
27
+ "config_sections": (
28
+ ("setup.cfg", "importlinter"),
29
+ ("tox.ini", "importlinter"),
30
+ ("pyproject.toml", "tool.importlinter"),
31
+ ),
32
+ "config_required": True,
33
+ },
34
+ "dependency-cruiser": {
35
+ "package": "dependency-cruiser",
36
+ "license": "MIT",
37
+ "kind": "architecture",
38
+ "description": "JavaScript and TypeScript dependency rules",
39
+ "executables": ("depcruise",),
40
+ "config_names": (
41
+ ".dependency-cruiser.js",
42
+ ".dependency-cruiser.cjs",
43
+ ".dependency-cruiser.mjs",
44
+ ".dependency-cruiser.json",
45
+ ),
46
+ "config_sections": (),
47
+ "config_required": True,
48
+ },
49
+ "repomix": {
50
+ "package": "repomix",
51
+ "license": "MIT",
52
+ "kind": "context",
53
+ "description": "Repository packing and context budget enforcement",
54
+ "executables": ("repomix",),
55
+ "config_names": ("repomix.config.json",),
56
+ "config_sections": (),
57
+ "config_required": False,
58
+ },
59
+ }
60
+
61
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
62
+ EXCERPT_LIMIT = 8000
63
+ DEFAULT_MAX_CHARS = 120000
64
+
65
+
66
+ def adapter_catalog():
67
+ """Return stable adapter metadata without touching the filesystem."""
68
+ items = []
69
+ for adapter_id, spec in ADAPTER_SPECS.items():
70
+ items.append({
71
+ "id": adapter_id,
72
+ "package": spec["package"],
73
+ "license": spec["license"],
74
+ "kind": spec["kind"],
75
+ "description": spec["description"],
76
+ "config_required": bool(spec["config_required"]),
77
+ })
78
+ return items
79
+
80
+
81
+ def _validated_root(path):
82
+ root = Path(path)
83
+ if not root.exists():
84
+ raise ValueError("path does not exist: %s" % path)
85
+ if not root.is_dir():
86
+ raise ValueError("path must be a directory: %s" % path)
87
+ return root.resolve()
88
+
89
+
90
+ def _validated_target(root, target):
91
+ raw = "." if target is None else str(target)
92
+ if not raw.strip():
93
+ raise ValueError("target must not be empty")
94
+ target_path = Path(raw)
95
+ if target_path.is_absolute():
96
+ raise ValueError("target must be repository-relative")
97
+ if ".." in target_path.parts:
98
+ raise ValueError("target must not escape the repository")
99
+ resolved = (root / target_path).resolve()
100
+ try:
101
+ resolved.relative_to(root)
102
+ except ValueError:
103
+ raise ValueError("target must stay inside the repository")
104
+ if not resolved.exists():
105
+ raise ValueError("target does not exist: %s" % raw)
106
+ rel = target_path.as_posix()
107
+ return resolved, rel if rel not in ("", ".") else "."
108
+
109
+
110
+ def _candidate_bin_dirs(root):
111
+ if os.name == "nt":
112
+ venv_dirs = (
113
+ root / ".venv" / "Scripts",
114
+ root / "venv" / "Scripts",
115
+ root / "env" / "Scripts",
116
+ )
117
+ else:
118
+ venv_dirs = (
119
+ root / ".venv" / "bin",
120
+ root / "venv" / "bin",
121
+ root / "env" / "bin",
122
+ )
123
+ return (root / "node_modules" / ".bin",) + venv_dirs
124
+
125
+
126
+ def _is_within(root, path):
127
+ try:
128
+ Path(path).resolve().relative_to(root)
129
+ return True
130
+ except ValueError:
131
+ return False
132
+
133
+
134
+ def _executable_source(root, executable):
135
+ executable = Path(executable)
136
+ if _is_within(root / "node_modules" / ".bin", executable):
137
+ return "project-bin"
138
+ if any(_is_within(directory, executable)
139
+ for directory in _candidate_bin_dirs(root)[1:]):
140
+ return "project-venv"
141
+ return "path"
142
+
143
+
144
+ def _display_path(root, path):
145
+ path = Path(path)
146
+ try:
147
+ return path.resolve().relative_to(root).as_posix()
148
+ except ValueError:
149
+ return path.name
150
+
151
+
152
+ def _find_executable(root, names):
153
+ for name in names:
154
+ for directory in _candidate_bin_dirs(root):
155
+ if not directory.is_dir():
156
+ continue
157
+ found = shutil.which(name, path=str(directory))
158
+ if found:
159
+ executable = Path(found)
160
+ return executable, _executable_source(root, executable)
161
+ found = shutil.which(name)
162
+ if found:
163
+ executable = Path(found)
164
+ return executable, _executable_source(root, executable)
165
+ return None, None
166
+
167
+
168
+ def _read_text(path):
169
+ try:
170
+ return path.read_text(encoding="utf-8")
171
+ except (OSError, UnicodeError):
172
+ return ""
173
+
174
+
175
+ def _find_config(root, spec):
176
+ for name in spec["config_names"]:
177
+ if (root / name).is_file():
178
+ return name
179
+ for name, section in spec["config_sections"]:
180
+ path = root / name
181
+ if not path.is_file():
182
+ continue
183
+ text = _read_text(path).lower()
184
+ if "[%s]" % section.lower() in text:
185
+ return name
186
+ return None
187
+
188
+
189
+ def _adapter_state(root, spec):
190
+ executable, source = _find_executable(root, spec["executables"])
191
+ config = _find_config(root, spec)
192
+ available = executable is not None
193
+ config_required = bool(spec["config_required"])
194
+ ready = available and (not config_required or config is not None)
195
+ reason = None
196
+ if not available:
197
+ reason = "adapter-not-installed"
198
+ elif config_required and not config:
199
+ reason = "adapter-config-missing"
200
+ return {
201
+ "id": next(key for key, value in ADAPTER_SPECS.items() if value is spec),
202
+ "package": spec["package"],
203
+ "license": spec["license"],
204
+ "kind": spec["kind"],
205
+ "description": spec["description"],
206
+ "available": available,
207
+ "ready": ready,
208
+ "executable": _display_path(root, executable) if executable else None,
209
+ "executable_source": source,
210
+ "config": config,
211
+ "config_required": config_required,
212
+ "reason": reason,
213
+ }
214
+
215
+
216
+ def _next_step_for_reason(reason, adapter_id):
217
+ if reason == "adapter-not-installed":
218
+ return "install %s in the project environment or PATH" % adapter_id
219
+ if reason == "adapter-config-missing":
220
+ return "add the adapter configuration file at the repository root"
221
+ if reason == "allow-execute-false":
222
+ return "rerun with allow_execute=true"
223
+ if reason == "adapter-output-invalid":
224
+ return "inspect the adapter output and configuration"
225
+ if reason == "adapter-exit-nonzero":
226
+ return "inspect the adapter output and rerun"
227
+ if reason == "adapter-timeout":
228
+ return "increase the timeout or narrow the adapter target"
229
+ if reason == "adapter-execution-failed":
230
+ return "verify the adapter executable and local environment"
231
+ return None
232
+
233
+
234
+ def _list_adapters(root):
235
+ adapters = []
236
+ unknowns = []
237
+ for spec in ADAPTER_SPECS.values():
238
+ state = _adapter_state(root, spec)
239
+ adapters.append(state)
240
+ if not state["ready"]:
241
+ unknowns.append({
242
+ "adapter": state["id"],
243
+ "kind": state["reason"],
244
+ "detail": "%s is not ready" % state["id"],
245
+ "next_step": _next_step_for_reason(state["reason"], state["id"]),
246
+ })
247
+ return {
248
+ "status": "PASS",
249
+ "action": "list",
250
+ "root": str(root),
251
+ "adapters": adapters,
252
+ "unknowns": unknowns,
253
+ "unverified_claims": [],
254
+ }
255
+
256
+
257
+ def _decode(value):
258
+ if value is None:
259
+ return ""
260
+ if isinstance(value, bytes):
261
+ return value.decode("utf-8", errors="replace")
262
+ return str(value)
263
+
264
+
265
+ def _clean_output(value):
266
+ return ANSI_RE.sub("", _decode(value)).replace("\r\n", "\n").replace("\r", "\n")
267
+
268
+
269
+ def _hash_output(stdout, stderr):
270
+ payload = stdout.encode("utf-8", errors="replace") + b"\x00" + \
271
+ stderr.encode("utf-8", errors="replace")
272
+ return "sha256:" + hashlib.sha256(payload).hexdigest()
273
+
274
+
275
+ def _bounded(text, max_chars):
276
+ if max_chars < 1:
277
+ raise ValueError("max_chars must be >= 1")
278
+ if len(text) <= max_chars:
279
+ return text, False
280
+ return text[:max_chars], True
281
+
282
+
283
+ def _child_env():
284
+ env = os.environ.copy()
285
+ env["PYTHONIOENCODING"] = "utf-8"
286
+ env["NO_COLOR"] = "1"
287
+ env["FORCE_COLOR"] = "0"
288
+ return env
289
+
290
+
291
+ def _run_process(argv, cwd, timeout):
292
+ """Run one fixed argv without a shell. Tests replace this boundary."""
293
+ return subprocess.run(
294
+ [str(item) for item in argv],
295
+ cwd=str(cwd),
296
+ capture_output=True,
297
+ timeout=timeout,
298
+ env=_child_env(),
299
+ )
300
+
301
+
302
+ def _adapter_version(executable, root, timeout):
303
+ try:
304
+ completed = _run_process([str(executable), "--version"], root, min(timeout, 10))
305
+ except Exception: # noqa: BLE001
306
+ return None
307
+ if completed.returncode != 0:
308
+ return None
309
+ text = _clean_output(completed.stdout or completed.stderr).strip()
310
+ if not text:
311
+ return None
312
+ return text.splitlines()[0].strip() or None
313
+
314
+
315
+ def _base_run_result(root, state):
316
+ adapter = dict(state)
317
+ adapter["version"] = None
318
+ return {
319
+ "status": "UNKNOWN",
320
+ "action": "run",
321
+ "root": str(root),
322
+ "adapter": adapter,
323
+ "command": None,
324
+ "findings": [],
325
+ "content": None,
326
+ "output_excerpt": "",
327
+ "truncated": False,
328
+ "reason": None,
329
+ "next_step": None,
330
+ "unknowns": [],
331
+ }
332
+
333
+
334
+ def _unknown_result(result, reason, detail=None):
335
+ result["status"] = "UNKNOWN"
336
+ result["reason"] = reason
337
+ result["next_step"] = _next_step_for_reason(reason, result["adapter"]["id"])
338
+ result["unknowns"] = [{
339
+ "kind": reason,
340
+ "detail": detail or result["next_step"] or reason,
341
+ }]
342
+ return result
343
+
344
+
345
+ def _import_linter_argv(executable, config):
346
+ return [str(executable), "--config", config]
347
+
348
+
349
+ def _dependency_cruiser_argv(executable, config, target):
350
+ return [
351
+ str(executable),
352
+ "--config", config,
353
+ "--output-type", "json",
354
+ "--progress", "none",
355
+ "--exclude", "node_modules",
356
+ target,
357
+ ]
358
+
359
+
360
+ def _repomix_argv(executable, target, token_budget):
361
+ argv = [
362
+ str(executable),
363
+ "--stdout",
364
+ "--style", "xml",
365
+ "--quiet",
366
+ "--no-git-sort-by-changes",
367
+ ]
368
+ if token_budget is not None:
369
+ argv.extend(["--token-budget", str(token_budget)])
370
+ argv.append(target)
371
+ return argv
372
+
373
+
374
+ def _import_linter_findings(output):
375
+ findings = []
376
+ current_rule = None
377
+ for line in output.splitlines():
378
+ stripped = line.strip()
379
+ broken = re.match(r"^(?P<rule>.+?)\s+BROKEN\s*$", stripped)
380
+ if broken:
381
+ current_rule = broken.group("rule").strip()
382
+ continue
383
+ violation = re.match(
384
+ r"^(?P<source>\S+)\s+is not allowed to import\s+(?P<target>\S+)",
385
+ stripped,
386
+ )
387
+ if violation:
388
+ source = violation.group("source").rstrip(":;, ")
389
+ target = violation.group("target").rstrip(":;, ")
390
+ findings.append({
391
+ "code": "adapter-violation",
392
+ "adapter": "import-linter",
393
+ "rule": current_rule or "lint-imports",
394
+ "severity": "high",
395
+ "path": source,
396
+ "line": None,
397
+ "target": target,
398
+ "message": stripped,
399
+ })
400
+ if not findings:
401
+ findings.append({
402
+ "code": "adapter-violation",
403
+ "adapter": "import-linter",
404
+ "rule": current_rule or "lint-imports",
405
+ "severity": "high",
406
+ "path": None,
407
+ "line": None,
408
+ "target": None,
409
+ "message": "import-linter reported a broken contract",
410
+ })
411
+ return findings
412
+
413
+
414
+ def _severity_from_dependency_cruiser(value):
415
+ normalized = str(value or "info").lower()
416
+ if normalized in ("error", "high", "critical"):
417
+ return "high"
418
+ if normalized in ("warn", "warning", "medium"):
419
+ return "medium"
420
+ return "low"
421
+
422
+
423
+ def _dependency_cruiser_findings(payload):
424
+ violations = ((payload.get("summary") or {}).get("violations")) or []
425
+ findings = []
426
+ for violation in violations:
427
+ rule = violation.get("rule") or {}
428
+ findings.append({
429
+ "code": "adapter-violation",
430
+ "adapter": "dependency-cruiser",
431
+ "rule": rule.get("name") or violation.get("name") or "dependency-cruiser",
432
+ "severity": _severity_from_dependency_cruiser(rule.get("severity")),
433
+ "path": violation.get("from"),
434
+ "line": None,
435
+ "target": violation.get("to"),
436
+ "message": violation.get("comment") or "dependency rule violation",
437
+ })
438
+ return findings
439
+
440
+
441
+ def _normalize_import_linter(result, returncode, stdout, stderr):
442
+ result["findings"] = _import_linter_findings(stdout or stderr)
443
+ if returncode == 0:
444
+ result["status"] = "PASS"
445
+ result["findings"] = []
446
+ elif returncode == 1:
447
+ result["status"] = "FAIL"
448
+ result["next_step"] = "fix the broken import-linter contract"
449
+ else:
450
+ _unknown_result(result, "adapter-exit-nonzero")
451
+ return result
452
+
453
+
454
+ def _normalize_dependency_cruiser(result, returncode, stdout, stderr):
455
+ try:
456
+ payload = json.loads(stdout)
457
+ except (ValueError, TypeError):
458
+ return _unknown_result(result, "adapter-output-invalid")
459
+ result["findings"] = _dependency_cruiser_findings(payload)
460
+ blocking = [item for item in result["findings"] if item["severity"] == "high"]
461
+ if blocking:
462
+ result["status"] = "FAIL"
463
+ result["next_step"] = "fix the dependency-cruiser violations"
464
+ elif returncode == 0:
465
+ result["status"] = "PASS"
466
+ else:
467
+ _unknown_result(result, "adapter-exit-nonzero")
468
+ return result
469
+
470
+
471
+ def _normalize_repomix(result, returncode, stdout, stderr, max_chars, token_budget):
472
+ text, truncated = _bounded(stdout, max_chars)
473
+ result["content"] = {
474
+ "style": "xml",
475
+ "chars": len(stdout),
476
+ "estimated_tokens": max(1, (len(stdout) + 3) // 4),
477
+ "token_budget": token_budget,
478
+ "content_hash": "sha256:" + hashlib.sha256(
479
+ stdout.encode("utf-8", errors="replace")
480
+ ).hexdigest(),
481
+ "truncated": truncated,
482
+ "text": text,
483
+ }
484
+ result["truncated"] = truncated
485
+ if returncode == 0:
486
+ result["status"] = "PASS"
487
+ elif token_budget is not None or "token budget" in (stderr + stdout).lower():
488
+ result["status"] = "FAIL"
489
+ result["findings"] = [{
490
+ "code": "token-budget-exceeded",
491
+ "adapter": "repomix",
492
+ "rule": "token-budget",
493
+ "severity": "high",
494
+ "path": None,
495
+ "line": None,
496
+ "target": None,
497
+ "message": "repomix output exceeded the configured token budget",
498
+ }]
499
+ result["next_step"] = "narrow the target or increase the token budget"
500
+ else:
501
+ _unknown_result(result, "adapter-exit-nonzero")
502
+ return result
503
+
504
+
505
+ def _validate_timeout(timeout):
506
+ if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 600:
507
+ raise ValueError("timeout must be an integer between 1 and 600")
508
+
509
+
510
+ def _validate_max_chars(max_chars):
511
+ if isinstance(max_chars, bool) or not isinstance(max_chars, int) or \
512
+ not 1000 <= max_chars <= 1000000:
513
+ raise ValueError("max_chars must be an integer between 1000 and 1000000")
514
+
515
+
516
+ def _validate_token_budget(token_budget):
517
+ if token_budget is None:
518
+ return
519
+ if isinstance(token_budget, bool) or not isinstance(token_budget, int) or \
520
+ not 1 <= token_budget <= 500000:
521
+ raise ValueError("token_budget must be an integer between 1 and 500000")
522
+
523
+
524
+ def run_adapter(path, action="list", adapter=None, allow_execute=False, timeout=120,
525
+ max_chars=DEFAULT_MAX_CHARS, token_budget=None, target="."):
526
+ """Probe or explicitly run one optional external adapter."""
527
+ root = _validated_root(path)
528
+ if action not in ("list", "run"):
529
+ raise ValueError("action must be list or run")
530
+ if action == "list":
531
+ return _list_adapters(root)
532
+ if adapter not in ADAPTER_SPECS:
533
+ raise ValueError("unknown adapter: %s" % adapter)
534
+ _validate_timeout(timeout)
535
+ _validate_max_chars(max_chars)
536
+ _validate_token_budget(token_budget)
537
+ target_path, target_rel = _validated_target(root, target)
538
+ del target_path
539
+
540
+ spec = ADAPTER_SPECS[adapter]
541
+ state = _adapter_state(root, spec)
542
+ result = _base_run_result(root, state)
543
+ if not state["available"]:
544
+ return _unknown_result(result, "adapter-not-installed")
545
+ if spec["config_required"] and not state["config"]:
546
+ return _unknown_result(result, "adapter-config-missing")
547
+ if not allow_execute:
548
+ return _unknown_result(result, "allow-execute-false")
549
+
550
+ executable = None
551
+ for directory in _candidate_bin_dirs(root):
552
+ if not directory.is_dir():
553
+ continue
554
+ executable = shutil.which(spec["executables"][0], path=str(directory))
555
+ if executable:
556
+ break
557
+ if not executable:
558
+ executable = shutil.which(spec["executables"][0])
559
+ if not executable:
560
+ return _unknown_result(result, "adapter-not-installed")
561
+ executable = Path(executable)
562
+ result["adapter"]["version"] = _adapter_version(executable, root, timeout)
563
+
564
+ if adapter == "import-linter":
565
+ argv = _import_linter_argv(executable, state["config"])
566
+ elif adapter == "dependency-cruiser":
567
+ argv = _dependency_cruiser_argv(executable, state["config"], target_rel)
568
+ else:
569
+ argv = _repomix_argv(executable, target_rel, token_budget)
570
+
571
+ try:
572
+ completed = _run_process(argv, root, timeout)
573
+ except subprocess.TimeoutExpired as exc:
574
+ stdout = _clean_output(exc.stdout)
575
+ stderr = _clean_output(exc.stderr)
576
+ result["command"] = {
577
+ "executable": result["adapter"]["executable"],
578
+ "argv": argv[1:],
579
+ "cwd": ".",
580
+ "exit_code": None,
581
+ "timed_out": True,
582
+ "output_hash": _hash_output(stdout, stderr),
583
+ }
584
+ result["output_excerpt"] = _bounded(stdout or stderr, EXCERPT_LIMIT)[0]
585
+ return _unknown_result(result, "adapter-timeout")
586
+ except OSError:
587
+ return _unknown_result(result, "adapter-execution-failed")
588
+
589
+ stdout = _clean_output(completed.stdout)
590
+ stderr = _clean_output(completed.stderr)
591
+ result["command"] = {
592
+ "executable": result["adapter"]["executable"],
593
+ "argv": argv[1:],
594
+ "cwd": ".",
595
+ "exit_code": int(completed.returncode),
596
+ "timed_out": False,
597
+ "output_hash": _hash_output(stdout, stderr),
598
+ }
599
+ result["output_excerpt"], excerpt_truncated = _bounded(
600
+ stdout or stderr, EXCERPT_LIMIT
601
+ )
602
+ if adapter == "repomix":
603
+ return _normalize_repomix(
604
+ result, int(completed.returncode), stdout, stderr, max_chars, token_budget
605
+ )
606
+ result["truncated"] = excerpt_truncated
607
+ if adapter == "import-linter":
608
+ return _normalize_import_linter(result, int(completed.returncode), stdout, stderr)
609
+ return _normalize_dependency_cruiser(result, int(completed.returncode), stdout, stderr)