Skip to content

Commit f8bfc34

Browse files
committed
fix(cli): route fatal diagnostics to stderr
Anything driving the CLI from a script separates the two streams and parses stdout. A diagnostic printed there is lost as a diagnostic — a failed scan left an empty error log and nothing to act on — and corrupting as output, since it lands in the same stream as the report. The mechanism already exists: err_console arrived with the author-shipped baseline notices, which correctly go to stderr. This commit only moves the diagnostics onto it. Thirteen call sites: every message that prints and then raises typer.Exit, the two print_exception() calls in the --verbose branches, and the per-skill error inside the multi-skill loop. --version stays on stdout, because that is program output rather than a diagnostic. Tests enumerate all thirteen paths and assert the message reaches stderr and never stdout. Verified red against the unmodified module: fourteen failures. The last test is the reason the others are not enough. Three of these sites were already on stdout before, and three more were added since — the invariant has no enforcement, so it regenerates. It parses cli.py and fails when error-styled output is written to the default console. Signed-off-by: Mark2Mac <Mark2Mac@users.noreply.github.com>
1 parent 52dd05f commit f8bfc34

2 files changed

Lines changed: 204 additions & 13 deletions

File tree

src/skillspector/cli.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -313,13 +313,15 @@ def scan(
313313
"""
314314
if mcp_registry:
315315
if recursive or baseline is not None or show_suppressed or yara_rules_dir is not None:
316-
console.print(
316+
err_console.print(
317317
"[red]Error:[/red] --mcp-registry cannot be combined with "
318318
"--recursive, --baseline, --show-suppressed, or --yara-rules-dir"
319319
)
320320
raise typer.Exit(code=2)
321321
if format != FormatChoice.json:
322-
console.print("[red]Error:[/red] --mcp-registry currently supports only --format json")
322+
err_console.print(
323+
"[red]Error:[/red] --mcp-registry currently supports only --format json"
324+
)
323325
raise typer.Exit(code=2)
324326
try:
325327
result = scan_registry(input_path)
@@ -334,7 +336,7 @@ def scan(
334336
except typer.Exit:
335337
raise
336338
except Exception as e:
337-
console.print(f"[red]Error:[/red] {e}")
339+
err_console.print(f"[red]Error:[/red] {e}")
338340
raise typer.Exit(code=2) from e
339341
return
340342

@@ -346,13 +348,13 @@ def scan(
346348
try:
347349
resolved_path = validate_local_input_path(resolved_path)
348350
except ValueError as e:
349-
console.print(f"[red]Error:[/red] {e}")
351+
err_console.print(f"[red]Error:[/red] {e}")
350352
raise typer.Exit(code=2) from e
351353
if recursive and resolved_path.is_dir():
352354
detection = detect_skills(resolved_path)
353355
if detection.is_multi_skill:
354356
if baseline is not None:
355-
console.print(
357+
err_console.print(
356358
"[red]Error:[/red] --baseline is not supported for recursive "
357359
"multi-skill scans; scan each sub-skill with its own baseline"
358360
)
@@ -428,13 +430,13 @@ def scan(
428430
except typer.Exit:
429431
raise
430432
except (FileNotFoundError, ValueError) as e:
431-
console.print(f"[red]Error:[/red] {e}")
433+
err_console.print(f"[red]Error:[/red] {e}")
432434
raise typer.Exit(code=2) from e
433435
except Exception as e:
434436
if verbose:
435-
console.print_exception()
437+
err_console.print_exception()
436438
else:
437-
console.print(f"[red]Error:[/red] {e}")
439+
err_console.print(f"[red]Error:[/red] {e}")
438440
raise typer.Exit(code=2) from e
439441
finally:
440442
if result is not None:
@@ -494,7 +496,7 @@ def _scan_multi_skill(
494496
severity = result.get("risk_severity") or "LOW"
495497
console.print(f" Score: {score}/100 ({severity})\n")
496498
except Exception as e:
497-
console.print(f" [red]Error:[/red] {e}\n")
499+
err_console.print(f" [red]Error:[/red] {e}\n")
498500
execution_failed = True
499501
results.append({"skill_name": skill.name, "error": str(e)})
500502

@@ -608,7 +610,7 @@ def mcp(
608610

609611
run_mcp(transport=transport.value, host=host, port=port)
610612
except ModuleNotFoundError as e:
611-
console.print(f"[red]Error:[/red] {e}")
613+
err_console.print(f"[red]Error:[/red] {e}")
612614
raise typer.Exit(code=2) from e
613615

614616

@@ -682,13 +684,13 @@ def baseline(
682684
except typer.Exit:
683685
raise
684686
except (FileNotFoundError, ValueError) as e:
685-
console.print(f"[red]Error:[/red] {e}")
687+
err_console.print(f"[red]Error:[/red] {e}")
686688
raise typer.Exit(code=2) from e
687689
except Exception as e:
688690
if verbose:
689-
console.print_exception()
691+
err_console.print_exception()
690692
else:
691-
console.print(f"[red]Error:[/red] {e}")
693+
err_console.print(f"[red]Error:[/red] {e}")
692694
raise typer.Exit(code=2) from e
693695
finally:
694696
if result is not None:

tests/unit/test_cli.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515

1616
"""Tests for skillspector CLI (skillspector scan, --version)."""
1717

18+
import ast
1819
import json
20+
import sys
21+
from collections.abc import Callable, Iterator
22+
from contextlib import AbstractContextManager, ExitStack, contextmanager, nullcontext
1923
from pathlib import Path
2024
from types import SimpleNamespace
2125
from typing import Any
@@ -27,6 +31,7 @@
2731
from typer.testing import CliRunner
2832

2933
from skillspector import __version__
34+
from skillspector import cli as cli_module
3035
from skillspector.cli import FormatChoice, _scan_multi_skill, app
3136
from skillspector.multi_skill import MultiSkillDetectionResult, SkillDirectory
3237

@@ -1133,3 +1138,187 @@ def fake_invoke(state: dict[str, Any], config: Any = None) -> dict[str, Any]:
11331138
assert payload["issues"] == [{"id": "X-1", "severity": "low"}]
11341139
assert payload["suppressed_count"] == 0
11351140
assert payload["suppressed"] == []
1141+
1142+
1143+
# --- Fatal diagnostics belong on stderr ---------------------------------------------------
1144+
#
1145+
# Anything driving the CLI from a script separates the two streams and parses stdout. A
1146+
# diagnostic printed there is both lost as a diagnostic and corrupting as output. The cases
1147+
# below enumerate every path that prints and then exits, so a new one cannot be added on the
1148+
# wrong stream without a test turning red.
1149+
1150+
FatalPath = tuple[list[str], AbstractContextManager[object]]
1151+
1152+
1153+
@contextmanager
1154+
def _all_of(*managers: AbstractContextManager[object]) -> Iterator[None]:
1155+
"""Enter several patches as one context, so a case can state more than one."""
1156+
with ExitStack() as stack:
1157+
for manager in managers:
1158+
stack.enter_context(manager)
1159+
yield
1160+
1161+
1162+
def _registry_payload(directory: Path) -> Path:
1163+
"""A registry input that parses, so an argument check is what fails."""
1164+
payload = directory / "registry.json"
1165+
payload.write_text('{"servers": []}', encoding="utf-8")
1166+
return payload
1167+
1168+
1169+
def _skill_dir(directory: Path) -> Path:
1170+
"""A minimal skill directory the CLI accepts as an input path."""
1171+
skill = directory / "skill"
1172+
skill.mkdir(exist_ok=True)
1173+
(skill / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
1174+
return skill
1175+
1176+
1177+
def _multi_skill(directory: Path) -> AbstractContextManager[object]:
1178+
"""Take the multi-skill branch without building two real skill trees."""
1179+
return patch(
1180+
"skillspector.cli.detect_skills",
1181+
return_value=MultiSkillDetectionResult(
1182+
is_multi_skill=True,
1183+
skills=[
1184+
SkillDirectory(path=directory / "one", name="one", relative_path="one"),
1185+
SkillDirectory(path=directory / "two", name="two", relative_path="two"),
1186+
],
1187+
has_root_skill=False,
1188+
),
1189+
)
1190+
1191+
1192+
def _scan_raises(exc: BaseException) -> AbstractContextManager[object]:
1193+
"""Make the graph blow up, which is how the generic handlers are reached."""
1194+
return patch("skillspector.cli.graph.invoke", side_effect=exc)
1195+
1196+
1197+
def _registry_flag_conflict(d: Path) -> FatalPath:
1198+
args = ["scan", str(_registry_payload(d)), "--mcp-registry", "--recursive"]
1199+
return args, nullcontext()
1200+
1201+
1202+
def _registry_wrong_format(d: Path) -> FatalPath:
1203+
args = ["scan", str(_registry_payload(d)), "--mcp-registry", "--format", "markdown"]
1204+
return args, nullcontext()
1205+
1206+
1207+
def _registry_scan_fails(d: Path) -> FatalPath:
1208+
args = ["scan", str(_registry_payload(d)), "--mcp-registry", "--format", "json"]
1209+
return args, patch(
1210+
"skillspector.cli.scan_registry", side_effect=RuntimeError("registry unreachable")
1211+
)
1212+
1213+
1214+
def _symlinked_input(d: Path) -> FatalPath:
1215+
link = d / "linked-skill"
1216+
try:
1217+
link.symlink_to(_skill_dir(d), target_is_directory=True)
1218+
except OSError:
1219+
pytest.skip("symlinks are not supported on this filesystem")
1220+
return ["scan", str(link), "--no-llm"], nullcontext()
1221+
1222+
1223+
def _recursive_multi_skill_with_baseline(d: Path) -> FatalPath:
1224+
args = ["scan", str(_skill_dir(d)), "--recursive", "--baseline", str(d / "b.yaml"), "--no-llm"]
1225+
return args, _multi_skill(d)
1226+
1227+
1228+
def _multi_skill_child_crashes(d: Path) -> FatalPath:
1229+
args = ["scan", str(_skill_dir(d)), "--recursive", "--no-llm"]
1230+
return args, _all_of(_multi_skill(d), _scan_raises(RuntimeError("child scan crashed")))
1231+
1232+
1233+
def _scan_input_missing(d: Path) -> FatalPath:
1234+
args = ["scan", str(_skill_dir(d)), "--no-llm"]
1235+
return args, _scan_raises(FileNotFoundError("skill vanished"))
1236+
1237+
1238+
def _scan_crashes(d: Path) -> FatalPath:
1239+
args = ["scan", str(_skill_dir(d)), "--no-llm"]
1240+
return args, _scan_raises(RuntimeError("scan crashed"))
1241+
1242+
1243+
def _scan_crashes_verbose(d: Path) -> FatalPath:
1244+
args = ["scan", str(_skill_dir(d)), "--no-llm", "--verbose"]
1245+
return args, _scan_raises(RuntimeError("scan crashed"))
1246+
1247+
1248+
def _baseline_input_missing(d: Path) -> FatalPath:
1249+
args = ["baseline", str(_skill_dir(d)), "--no-llm", "-o", str(d / "b.yaml")]
1250+
return args, _scan_raises(FileNotFoundError("baseline input missing"))
1251+
1252+
1253+
def _baseline_crashes(d: Path) -> FatalPath:
1254+
args = ["baseline", str(_skill_dir(d)), "--no-llm", "-o", str(d / "b.yaml")]
1255+
return args, _scan_raises(RuntimeError("baseline crashed"))
1256+
1257+
1258+
def _baseline_crashes_verbose(d: Path) -> FatalPath:
1259+
args = ["baseline", str(_skill_dir(d)), "--no-llm", "-o", str(d / "b.yaml"), "--verbose"]
1260+
return args, _scan_raises(RuntimeError("baseline crashed"))
1261+
1262+
1263+
def _mcp_module_missing(d: Path) -> FatalPath:
1264+
return ["mcp"], patch.dict(sys.modules, {"skillspector.mcp_server": None})
1265+
1266+
1267+
@pytest.mark.parametrize(
1268+
("build", "needle"),
1269+
[
1270+
pytest.param(_registry_flag_conflict, "cannot be combined", id="registry-flag-conflict"),
1271+
pytest.param(_registry_wrong_format, "supports only --format json", id="registry-format"),
1272+
pytest.param(_registry_scan_fails, "registry unreachable", id="registry-scan-fails"),
1273+
pytest.param(_symlinked_input, "Refusing to resolve", id="symlinked-input"),
1274+
pytest.param(
1275+
_recursive_multi_skill_with_baseline,
1276+
"not supported for recursive",
1277+
id="recursive-baseline",
1278+
),
1279+
pytest.param(_multi_skill_child_crashes, "child scan crashed", id="multi-skill-child"),
1280+
pytest.param(_scan_input_missing, "skill vanished", id="scan-input-missing"),
1281+
pytest.param(_scan_crashes, "scan crashed", id="scan-crashes"),
1282+
pytest.param(_scan_crashes_verbose, "RuntimeError", id="scan-crashes-verbose"),
1283+
pytest.param(_baseline_input_missing, "baseline input missing", id="baseline-missing"),
1284+
pytest.param(_baseline_crashes, "baseline crashed", id="baseline-crashes"),
1285+
pytest.param(_baseline_crashes_verbose, "RuntimeError", id="baseline-verbose"),
1286+
pytest.param(_mcp_module_missing, "skillspector.mcp_server", id="mcp-module-missing"),
1287+
],
1288+
)
1289+
def test_fatal_diagnostics_never_reach_stdout(
1290+
tmp_path: Path, build: Callable[[Path], FatalPath], needle: str
1291+
) -> None:
1292+
"""A path that prints and exits writes to stderr, leaving stdout machine-readable."""
1293+
args, ctx = build(tmp_path)
1294+
1295+
with ctx:
1296+
result = runner.invoke(app, args)
1297+
1298+
assert result.exit_code == 2
1299+
assert needle in result.stderr
1300+
assert needle not in result.stdout
1301+
1302+
1303+
def test_cli_writes_no_error_styled_output_to_stdout() -> None:
1304+
"""Guards new code: the invariant above regressed twice because nothing enforced it."""
1305+
tree = ast.parse(Path(cli_module.__file__).read_text(encoding="utf-8"))
1306+
offenders: list[tuple[int, str]] = []
1307+
for node in ast.walk(tree):
1308+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
1309+
continue
1310+
target = node.func.value
1311+
if not isinstance(target, ast.Name) or target.id != "console":
1312+
continue
1313+
if node.func.attr == "print_exception":
1314+
offenders.append((node.lineno, "print_exception()"))
1315+
elif node.func.attr == "print":
1316+
text = " ".join(
1317+
part.value
1318+
for part in ast.walk(node)
1319+
if isinstance(part, ast.Constant) and isinstance(part.value, str)
1320+
)
1321+
if "[red]Error:" in text:
1322+
offenders.append((node.lineno, text[:60]))
1323+
1324+
assert offenders == [], f"error output must use err_console, found on stdout: {offenders}"

0 commit comments

Comments
 (0)