Skip to content

Commit 3bfc5f4

Browse files
committed
fix(cli,supply-chain): parse package.json as JSON, and send fatal errors to stderr
Two correctness fixes, both found while driving the CLI from automation. package.json was scanned line by line. A manifest written on a single line — valid JSON, and what several generators emit — never entered the dependency section, so it produced *no* dependencies at all and the file passed silently. That is not noise, it is blindness: the scanner reports nothing and the caller cannot tell the difference from a clean manifest. It is now parsed as JSON. Version extraction is unchanged, including the caret handling, which is a separate question (#302): only the parsing changes. Line numbers survive the switch — the entry is located from the section header onwards, so a name that also appears in "scripts" does not steal the position — and a manifest that does not parse still falls back to the previous scan rather than going blind. Fatal errors were printed with the default Rich console, which writes to stdout. Anything driving the CLI from a script separates the two streams, so the only diagnosis available was discarded: a scan that failed left an empty error log and nothing to act on. Concretely, "Error: unsupported baseline version 1" — which is exactly the message a user needs after upgrading — arrived on stdout. Errors now go to a stderr console. Tests: one-line manifest, compact manifest, line numbers preserved, a name shadowed by "scripts", invalid JSON falling back, a non-object manifest, and non-string specs ignored. Full suite: 1567 passed, 14 skipped, 6 xfailed. Signed-off-by: Mark2Mac <Mark2Mac@users.noreply.github.com>
1 parent 7e9c19d commit 3bfc5f4

3 files changed

Lines changed: 110 additions & 11 deletions

File tree

src/skillspector/cli.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ def _ensure_utf8_streams() -> None:
7171
)
7272

7373
console = Console()
74+
# Fatal errors go to stderr. Anything driving the CLI from a script separates the two streams,
75+
# and with the message on stdout the only diagnosis available was thrown away: a failed scan
76+
# left an empty error log and the caller had nothing to act on.
77+
err_console = Console(stderr=True)
7478

7579

7680
class FormatChoice(StrEnum):
@@ -378,13 +382,13 @@ def scan(
378382
except typer.Exit:
379383
raise
380384
except (FileNotFoundError, ValueError) as e:
381-
console.print(f"[red]Error:[/red] {e}")
385+
err_console.print(f"[red]Error:[/red] {e}")
382386
raise typer.Exit(code=2) from e
383387
except Exception as e:
384388
if verbose:
385389
console.print_exception()
386390
else:
387-
console.print(f"[red]Error:[/red] {e}")
391+
err_console.print(f"[red]Error:[/red] {e}")
388392
raise typer.Exit(code=2) from e
389393
finally:
390394
if result is not None:
@@ -444,7 +448,7 @@ def _scan_multi_skill(
444448
severity = result.get("risk_severity") or "LOW"
445449
console.print(f" Score: {score}/100 ({severity})\n")
446450
except Exception as e:
447-
console.print(f" [red]Error:[/red] {e}\n")
451+
err_console.print(f" [red]Error:[/red] {e}\n")
448452
execution_failed = True
449453
results.append({"skill_name": skill.name, "error": str(e)})
450454

@@ -558,7 +562,7 @@ def mcp(
558562

559563
run_mcp(transport=transport.value, host=host, port=port)
560564
except ModuleNotFoundError as e:
561-
console.print(f"[red]Error:[/red] {e}")
565+
err_console.print(f"[red]Error:[/red] {e}")
562566
raise typer.Exit(code=2) from e
563567

564568

@@ -631,13 +635,13 @@ def baseline(
631635
except typer.Exit:
632636
raise
633637
except (FileNotFoundError, ValueError) as e:
634-
console.print(f"[red]Error:[/red] {e}")
638+
err_console.print(f"[red]Error:[/red] {e}")
635639
raise typer.Exit(code=2) from e
636640
except Exception as e:
637641
if verbose:
638642
console.print_exception()
639643
else:
640-
console.print(f"[red]Error:[/red] {e}")
644+
err_console.print(f"[red]Error:[/red] {e}")
641645
raise typer.Exit(code=2) from e
642646
finally:
643647
if result is not None:

src/skillspector/nodes/analyzers/static_patterns_supply_chain.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from __future__ import annotations
2929

30+
import json
3031
import re
3132
import sys
3233
import tomllib
@@ -544,8 +545,23 @@ def _extract_packages_from_requirements(content: str) -> list[tuple[str, str | N
544545
return results
545546

546547

547-
def _extract_packages_from_package_json(content: str) -> list[tuple[str, str | None, int]]:
548-
"""Extract (package_name, version_or_None, line_number) from package.json content."""
548+
_NPM_DEPENDENCY_SECTIONS = ("dependencies", "devDependencies", "peerDependencies")
549+
550+
551+
def _package_json_line(content: str, section: str, name: str) -> int:
552+
"""Best-effort line for a dependency entry, so findings keep pointing somewhere useful.
553+
554+
Parsing JSON loses positions, and the search starts at the section header so a name that
555+
also appears in ``scripts`` does not win.
556+
"""
557+
header = re.search(rf'"{re.escape(section)}"\s*:', content)
558+
start = header.end() if header else 0
559+
entry = re.compile(rf'"{re.escape(name)}"\s*:').search(content, start)
560+
return get_line_number(content, entry.start()) if entry else 1
561+
562+
563+
def _extract_packages_from_package_json_scan(content: str) -> list[tuple[str, str | None, int]]:
564+
"""Line-oriented fallback, used only when the manifest is not valid JSON."""
549565
results: list[tuple[str, str | None, int]] = []
550566
in_deps = False
551567
for i, line in enumerate(content.splitlines(), 1):
@@ -559,9 +575,34 @@ def _extract_packages_from_package_json(content: str) -> list[tuple[str, str | N
559575
if in_deps:
560576
m = re.match(r'"([^"]+)"\s*:\s*"([^"]*)"', stripped)
561577
if m:
562-
name = m.group(1)
563-
version = _pinned_npm_version(m.group(2))
564-
results.append((name, version, i))
578+
results.append((m.group(1), _pinned_npm_version(m.group(2)), i))
579+
return results
580+
581+
582+
def _extract_packages_from_package_json(content: str) -> list[tuple[str, str | None, int]]:
583+
"""Extract (package_name, version_or_None, line_number) from package.json content.
584+
585+
package.json is JSON, so it is parsed as JSON. Scanning it line by line made the result
586+
depend on formatting: a manifest written on a single line — which is valid, and what many
587+
generators emit — never entered the dependency section at all and yielded *no* dependencies,
588+
silently. The line-oriented scan remains as a fallback for manifests that do not parse.
589+
"""
590+
try:
591+
data = json.loads(content)
592+
except (ValueError, TypeError):
593+
return _extract_packages_from_package_json_scan(content)
594+
if not isinstance(data, dict):
595+
return []
596+
results: list[tuple[str, str | None, int]] = []
597+
for section in _NPM_DEPENDENCY_SECTIONS:
598+
deps = data.get(section)
599+
if not isinstance(deps, dict):
600+
continue
601+
for name, spec in deps.items():
602+
if not isinstance(name, str) or not isinstance(spec, str):
603+
continue
604+
line = _package_json_line(content, section, name)
605+
results.append((name, _pinned_npm_version(spec), line))
565606
return results
566607

567608

tests/unit/test_patterns_new.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,60 @@ def test_extract_packages_package_json_caret_is_not_a_pin(self) -> None:
15321532
assert versions["shell-quote"] is None
15331533
assert versions["semver"] is None
15341534
assert versions["glob"] is None
1535+
def test_package_json_on_a_single_line_is_not_invisible(self) -> None:
1536+
# Regression: the line-oriented scan never entered the dependency section, so a valid
1537+
# one-line manifest yielded no dependencies at all — silently.
1538+
content = '{"name":"x","dependencies":{"express":"^4.18.0","lodash":"4.17.21"}}'
1539+
names = {p[0] for p in sc_mod._extract_packages_from_package_json(content)}
1540+
assert names == {"express", "lodash"}
1541+
1542+
def test_package_json_compact_keeps_versions(self) -> None:
1543+
# Version resolution is not this PR's subject: it stays whatever the shared predicate
1544+
# decides (#319). Only the parsing of the manifest changes, and a compact manifest must
1545+
# resolve exactly like the indented one.
1546+
content = '{"dependencies":{"lodash":"4.17.21","semver":"^7.5.0"}}'
1547+
versions = {p[0]: p[1] for p in sc_mod._extract_packages_from_package_json(content)}
1548+
assert versions["lodash"] == "4.17.21"
1549+
assert versions["semver"] is None
1550+
1551+
def test_package_json_line_numbers_survive_parsing(self) -> None:
1552+
content = (
1553+
"{\n"
1554+
' "name": "x",\n'
1555+
' "dependencies": {\n'
1556+
' "express": "4.18.0"\n'
1557+
" }\n"
1558+
"}\n"
1559+
)
1560+
lines = {p[0]: p[2] for p in sc_mod._extract_packages_from_package_json(content)}
1561+
assert lines["express"] == 4
1562+
1563+
def test_package_json_line_prefers_the_dependency_over_a_script(self) -> None:
1564+
# A name that also appears in "scripts" must not steal the line number.
1565+
content = (
1566+
"{\n"
1567+
' "scripts": { "express": "node server.js" },\n'
1568+
' "dependencies": {\n'
1569+
' "express": "4.18.0"\n'
1570+
" }\n"
1571+
"}\n"
1572+
)
1573+
lines = {p[0]: p[2] for p in sc_mod._extract_packages_from_package_json(content)}
1574+
assert lines["express"] == 4
1575+
1576+
def test_package_json_invalid_falls_back_to_the_scan(self) -> None:
1577+
# A manifest that does not parse keeps the previous behaviour instead of going blind.
1578+
content = '{\n "dependencies": {\n "express": "4.18.0",\n' # truncated
1579+
names = {p[0] for p in sc_mod._extract_packages_from_package_json(content)}
1580+
assert "express" in names
1581+
1582+
def test_package_json_non_object_is_empty(self) -> None:
1583+
assert sc_mod._extract_packages_from_package_json("[1, 2, 3]") == []
1584+
1585+
def test_package_json_ignores_non_string_specs(self) -> None:
1586+
content = '{"dependencies":{"ok":"1.0.0","broken":{"version":"1.0.0"},"n":42}}'
1587+
names = {p[0] for p in sc_mod._extract_packages_from_package_json(content)}
1588+
assert names == {"ok"}
15351589

15361590
def test_extract_packages_package_json(self) -> None:
15371591
content = (

0 commit comments

Comments
 (0)