|
15 | 15 |
|
16 | 16 | """Tests for skillspector CLI (skillspector scan, --version).""" |
17 | 17 |
|
| 18 | +import ast |
18 | 19 | import json |
| 20 | +import sys |
| 21 | +from collections.abc import Callable, Iterator |
| 22 | +from contextlib import AbstractContextManager, ExitStack, contextmanager, nullcontext |
19 | 23 | from pathlib import Path |
20 | 24 | from types import SimpleNamespace |
21 | 25 | from typing import Any |
|
27 | 31 | from typer.testing import CliRunner |
28 | 32 |
|
29 | 33 | from skillspector import __version__ |
| 34 | +from skillspector import cli as cli_module |
30 | 35 | from skillspector.cli import FormatChoice, _scan_multi_skill, app |
31 | 36 | from skillspector.multi_skill import MultiSkillDetectionResult, SkillDirectory |
32 | 37 |
|
@@ -1133,3 +1138,187 @@ def fake_invoke(state: dict[str, Any], config: Any = None) -> dict[str, Any]: |
1133 | 1138 | assert payload["issues"] == [{"id": "X-1", "severity": "low"}] |
1134 | 1139 | assert payload["suppressed_count"] == 0 |
1135 | 1140 | 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