diff --git a/src/skillspector/cli.py b/src/skillspector/cli.py index 25e78dfd..992d16a0 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -70,6 +70,10 @@ def _ensure_utf8_streams() -> None: ) console = Console() +# Fatal errors go to stderr. Anything driving the CLI from a script separates the two streams, +# and with the message on stdout the only diagnosis available was thrown away: a failed scan +# left an empty error log and the caller had nothing to act on. +err_console = Console(stderr=True) class FormatChoice(StrEnum): @@ -343,13 +347,13 @@ def scan( except typer.Exit: raise except (FileNotFoundError, ValueError) as e: - console.print(f"[red]Error:[/red] {e}") + err_console.print(f"[red]Error:[/red] {e}") raise typer.Exit(code=2) from e except Exception as e: if verbose: - console.print_exception() + err_console.print_exception() else: - console.print(f"[red]Error:[/red] {e}") + err_console.print(f"[red]Error:[/red] {e}") raise typer.Exit(code=2) from e finally: if result is not None: @@ -409,7 +413,7 @@ def _scan_multi_skill( severity = result.get("risk_severity") or "LOW" console.print(f" Score: {score}/100 ({severity})\n") except Exception as e: - console.print(f" [red]Error:[/red] {e}\n") + err_console.print(f" [red]Error:[/red] {e}\n") execution_failed = True results.append({"skill_name": skill.name, "error": str(e)}) @@ -523,7 +527,7 @@ def mcp( run_mcp(transport=transport.value, host=host, port=port) except ModuleNotFoundError as e: - console.print(f"[red]Error:[/red] {e}") + err_console.print(f"[red]Error:[/red] {e}") raise typer.Exit(code=2) from e @@ -596,13 +600,13 @@ def baseline( except typer.Exit: raise except (FileNotFoundError, ValueError) as e: - console.print(f"[red]Error:[/red] {e}") + err_console.print(f"[red]Error:[/red] {e}") raise typer.Exit(code=2) from e except Exception as e: if verbose: - console.print_exception() + err_console.print_exception() else: - console.print(f"[red]Error:[/red] {e}") + err_console.print(f"[red]Error:[/red] {e}") raise typer.Exit(code=2) from e finally: if result is not None: diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 5bbba8e2..ff334dd6 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -27,6 +27,7 @@ from __future__ import annotations +import json import re import sys import tomllib @@ -432,8 +433,29 @@ def _extract_packages_from_requirements(content: str) -> list[tuple[str, str | N return results -def _extract_packages_from_package_json(content: str) -> list[tuple[str, str | None, int]]: - """Extract (package_name, version_or_None, line_number) from package.json content.""" +_NPM_DEPENDENCY_SECTIONS = ("dependencies", "devDependencies", "peerDependencies") + + +def _npm_version_or_none(spec: str) -> str | None: + """Version of an npm dependency spec, or None when it does not start with a digit.""" + ver_str = spec.lstrip("^~>=<") + return ver_str if re.match(r"^\d", ver_str) else None + + +def _package_json_line(content: str, section: str, name: str) -> int: + """Best-effort line for a dependency entry, so findings keep pointing somewhere useful. + + Parsing JSON loses positions, and the search starts at the section header so a name that + also appears in ``scripts`` does not win. + """ + header = re.search(rf'"{re.escape(section)}"\s*:', content) + start = header.end() if header else 0 + entry = re.compile(rf'"{re.escape(name)}"\s*:').search(content, start) + return get_line_number(content, entry.start()) if entry else 1 + + +def _extract_packages_from_package_json_scan(content: str) -> list[tuple[str, str | None, int]]: + """Line-oriented fallback, used only when the manifest is not valid JSON.""" results: list[tuple[str, str | None, int]] = [] in_deps = False for i, line in enumerate(content.splitlines(), 1): @@ -447,10 +469,33 @@ def _extract_packages_from_package_json(content: str) -> list[tuple[str, str | N if in_deps: m = re.match(r'"([^"]+)"\s*:\s*"([^"]*)"', stripped) if m: - name = m.group(1) - ver_str = m.group(2).lstrip("^~>=<") - version = ver_str if re.match(r"^\d", ver_str) else None - results.append((name, version, i)) + results.append((m.group(1), _npm_version_or_none(m.group(2)), i)) + return results + + +def _extract_packages_from_package_json(content: str) -> list[tuple[str, str | None, int]]: + """Extract (package_name, version_or_None, line_number) from package.json content. + + package.json is JSON, so it is parsed as JSON. Scanning it line by line made the result + depend on formatting: a manifest written on a single line — which is valid, and what many + generators emit — never entered the dependency section at all and yielded *no* dependencies, + silently. The line-oriented scan remains as a fallback for manifests that do not parse. + """ + try: + data = json.loads(content) + except (ValueError, TypeError): + return _extract_packages_from_package_json_scan(content) + if not isinstance(data, dict): + return [] + results: list[tuple[str, str | None, int]] = [] + for section in _NPM_DEPENDENCY_SECTIONS: + deps = data.get(section) + if not isinstance(deps, dict): + continue + for name, spec in deps.items(): + if not isinstance(name, str) or not isinstance(spec, str): + continue + results.append((name, _npm_version_or_none(spec), _package_json_line(content, section, name))) return results diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index ea0c3fc0..5722391d 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -690,3 +690,30 @@ def fake_invoke(state: dict[str, Any], config: Any = None) -> dict[str, Any]: assert payload["issues"] == [{"id": "X-1", "severity": "low"}] assert payload["suppressed_count"] == 0 assert payload["suppressed"] == [] + + +def test_scan_verbose_traceback_goes_to_stderr(tmp_path: Path) -> None: + """A fatal --verbose traceback belongs on stderr, so stdout stays parseable.""" + (tmp_path / "SKILL.md").write_text("# Boom", encoding="utf-8") + + with patch("skillspector.cli.graph.invoke", side_effect=RuntimeError("scan crashed")): + result = runner.invoke(app, ["scan", str(tmp_path), "--no-llm", "--verbose"]) + + assert result.exit_code == 2 + assert "RuntimeError" in result.stderr + assert "RuntimeError" not in result.stdout + + +def test_baseline_verbose_traceback_goes_to_stderr(tmp_path: Path) -> None: + """Same separation for `baseline`, which shares the generic --verbose handler.""" + (tmp_path / "SKILL.md").write_text("# Boom", encoding="utf-8") + + with patch("skillspector.cli.graph.invoke", side_effect=RuntimeError("baseline crashed")): + result = runner.invoke( + app, + ["baseline", str(tmp_path), "--no-llm", "--verbose", "-o", str(tmp_path / "b.yaml")], + ) + + assert result.exit_code == 2 + assert "RuntimeError" in result.stderr + assert "RuntimeError" not in result.stdout diff --git a/tests/unit/test_patterns_new.py b/tests/unit/test_patterns_new.py index 9173e499..5e9b67c6 100644 --- a/tests/unit/test_patterns_new.py +++ b/tests/unit/test_patterns_new.py @@ -1391,6 +1391,60 @@ def test_extract_packages_requirements(self) -> None: assert "numpy" in names assert "flask" in names + def test_package_json_on_a_single_line_is_not_invisible(self) -> None: + # Regression: the line-oriented scan never entered the dependency section, so a valid + # one-line manifest yielded no dependencies at all — silently. + content = '{"name":"x","dependencies":{"express":"^4.18.0","lodash":"4.17.21"}}' + names = {p[0] for p in sc_mod._extract_packages_from_package_json(content)} + assert names == {"express", "lodash"} + + def test_package_json_compact_keeps_versions(self) -> None: + # Version extraction is unchanged by this PR — including the caret handling, which is + # a separate question (#302). Only the parsing of the manifest changes. + content = '{"dependencies":{"lodash":"4.17.21","semver":"^7.5.0"}}' + versions = {p[0]: p[1] for p in sc_mod._extract_packages_from_package_json(content)} + assert versions["lodash"] == "4.17.21" + assert versions["semver"] == "7.5.0" + + def test_package_json_line_numbers_survive_parsing(self) -> None: + content = ( + "{\n" + ' "name": "x",\n' + ' "dependencies": {\n' + ' "express": "4.18.0"\n' + " }\n" + "}\n" + ) + lines = {p[0]: p[2] for p in sc_mod._extract_packages_from_package_json(content)} + assert lines["express"] == 4 + + def test_package_json_line_prefers_the_dependency_over_a_script(self) -> None: + # A name that also appears in "scripts" must not steal the line number. + content = ( + "{\n" + ' "scripts": { "express": "node server.js" },\n' + ' "dependencies": {\n' + ' "express": "4.18.0"\n' + " }\n" + "}\n" + ) + lines = {p[0]: p[2] for p in sc_mod._extract_packages_from_package_json(content)} + assert lines["express"] == 4 + + def test_package_json_invalid_falls_back_to_the_scan(self) -> None: + # A manifest that does not parse keeps the previous behaviour instead of going blind. + content = '{\n "dependencies": {\n "express": "4.18.0",\n' # truncated + names = {p[0] for p in sc_mod._extract_packages_from_package_json(content)} + assert "express" in names + + def test_package_json_non_object_is_empty(self) -> None: + assert sc_mod._extract_packages_from_package_json("[1, 2, 3]") == [] + + def test_package_json_ignores_non_string_specs(self) -> None: + content = '{"dependencies":{"ok":"1.0.0","broken":{"version":"1.0.0"},"n":42}}' + names = {p[0] for p in sc_mod._extract_packages_from_package_json(content)} + assert names == {"ok"} + def test_extract_packages_package_json(self) -> None: content = ( '{\n "dependencies": {\n "express": "^4.18.0",\n "lodash": "4.17.21"\n }\n}'