Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions src/skillspector/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the generic --verbose handlers in both scan() and baseline() still call console.print_exception(), which writes the fatal traceback to stdout. I reproduced exit 2 with the full traceback in stdout and empty stderr. Please use err_console.print_exception() for those branches and add a regression that asserts stdout/stderr separation under --verbose.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f617861 — the objection was correct, and it was the same defect this PR set out to fix, left in the one branch where it is hardest to notice.

Both generic handlers now print through err_console, so scan --verbose and baseline --verbose behave like the one-line error paths. Reproduced your case first: with graph.invoke raising, the traceback was in stdout and result.stderr was empty.

Regression covers both commands (tests/unit/test_cli.py): exit code 2, RuntimeError present in stderr and absent from stdout. Verified it fails on the previous commit.

grep -rn print_exception src/ now returns only those two lines, both on err_console. Full suite: 1572 passed, 12 skipped, 6 xfailed.



class FormatChoice(StrEnum):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)})

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
57 changes: 51 additions & 6 deletions src/skillspector/nodes/analyzers/static_patterns_supply_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

from __future__ import annotations

import json
import re
import sys
import tomllib
Expand Down Expand Up @@ -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):
Expand All @@ -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


Expand Down
27 changes: 27 additions & 0 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
54 changes: 54 additions & 0 deletions tests/unit/test_patterns_new.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
Expand Down