@yottameta/yotta-dev-mcp-plugin 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +3 -3
- package/.claude-plugin/marketplace.json +3 -3
- package/package.json +1 -1
- package/plugin.json +2 -2
- package/skills/yotta-dev-mcp/SKILL.md +24 -9
- package/skills/yotta-dev-mcp/references/adapters.md +69 -0
- package/skills/yotta-dev-mcp/references/architecture-contract.md +256 -0
- package/skills/yotta-dev-mcp/references/tools.md +182 -3
- package/skills/yotta-dev-mcp/scripts/dev_adapters.py +609 -0
- package/skills/yotta-dev-mcp/scripts/dev_architecture.py +379 -0
- package/skills/yotta-dev-mcp/scripts/dev_common.py +155 -0
- package/skills/yotta-dev-mcp/scripts/dev_contract.py +830 -0
- package/skills/yotta-dev-mcp/scripts/dev_engine.py +328 -210
- package/skills/yotta-dev-mcp/scripts/dev_impact.py +556 -0
- package/skills/yotta-dev-mcp/scripts/dev_mcp_doctor.py +768 -0
- package/skills/yotta-dev-mcp/scripts/dev_model.py +447 -0
- package/skills/yotta-dev-mcp/scripts/dev_selftest.py +544 -0
- package/skills/yotta-dev-mcp/scripts/dev_verify.py +450 -0
- package/skills/yotta-dev-mcp/scripts/yotta_dev_mcp.py +246 -7
- package/skills/yotta-dev-mcp/server.json +3 -3
|
@@ -0,0 +1,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)
|