Skip to content
Draft
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
11 changes: 10 additions & 1 deletion src/patchorder/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def _parser() -> argparse.ArgumentParser:
inv_check.add_argument("inventory", type=Path)
inv_check.add_argument("-l", "--lock", type=Path, help="lock file (default: next to the inventory)")
inv_check.add_argument("--strict", action="store_true", help="exit 4 on warnings too")
inv_check.add_argument("--json", action="store_true", help="emit notes as JSON array (level, asset_id, text)")
inv_check.set_defaults(func=cmd_inventory_check)
inv_csv = inv_sub.add_parser("from-csv", help="turn a CSV export into inventory YAML (printed unless --output)")
inv_csv.add_argument("csv", type=Path)
Expand Down Expand Up @@ -353,9 +354,17 @@ def _maybe_llm(args, report) -> None:


def cmd_inventory_check(args: argparse.Namespace) -> int:
from patchorder.inventorytools import check_inventory
from patchorder.inventorytools import check_inventory, notes_to_json

notes = check_inventory(args.inventory, args.lock)
if args.json:
print(notes_to_json(notes))
counts = {"error": 0, "warning": 0, "info": 0}
for note in notes:
counts[note.level] += 1
if args.strict and counts["warning"]:
return 4
return 0
counts = {"error": 0, "warning": 0, "info": 0}
for note in notes:
counts[note.level] += 1
Expand Down
7 changes: 6 additions & 1 deletion src/patchorder/inventorytools.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import csv
import io
import json
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from pathlib import Path


Expand Down Expand Up @@ -67,6 +67,11 @@ def check_inventory(path: Path, lock_path: Path | None = None) -> list[Inventory
return notes


def notes_to_json(notes: list[InventoryNote]) -> str:
"""Serialize inventory notes as a JSON array with stable key order."""
return json.dumps([asdict(note) for note in notes], ensure_ascii=False, separators=(",", ":"))


def _normalize_header(name: str) -> str:
key = name.strip().lower().replace(" ", "_")
return _HEADER_ALIASES.get(key, key)
Expand Down
67 changes: 67 additions & 0 deletions tests/test_v02_gate_csv_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,70 @@ def test_cli_from_csv_writes_only_with_output(tmp_path, capsys):
out = tmp_path / "inventory.yaml"
assert main(["inventory", "from-csv", str(src), "--output", str(out)]) == 0
assert load_inventory(out).assets[0].version == "1.27.2"


def test_inventory_check_json_empty(tmp_path, capsys):
inv = tmp_path / "inv.yaml"
inv.write_text(
"schema: patchorder.inventory/v1\nname: t\nassets:\n"
" - {id: a, product: nginx, version: \"1.25.2\", count: 1, owner: o, exposure: internal}\n",
encoding="utf-8",
)
(tmp_path / "inv.lock.yaml").write_text(
"schema: patchorder.lock/v1\nentries:\n a:\n cpe: cpe:2.3:a:f5:nginx\n pinned: true\n origin: user\n",
encoding="utf-8",
)
assert main(["inventory", "check", str(inv), "--json"]) == 0
out = capsys.readouterr().out.strip()
assert out == "[]"


def test_inventory_check_json_with_notes(tmp_path, capsys):
inv = tmp_path / "inv.yaml"
inv.write_text(
"schema: patchorder.inventory/v1\nname: t\nassets:\n"
" - {id: a, product: Example Thing, version: \"1.0\", count: 0, owner: o, exposure: internal}\n"
" - {id: b, product: Example Thing, version: \"1.0\", count: 2, owner: o, exposure: internal}\n",
encoding="utf-8",
)
(tmp_path / "inv.lock.yaml").write_text(
"schema: patchorder.lock/v1\nentries:\n gone:\n cpe: cpe:2.3:a:example:thing\n pinned: true\n origin: user\n",
encoding="utf-8",
)
assert main(["inventory", "check", str(inv), "--json"]) == 0
out = capsys.readouterr().out.strip()
import json as jsonlib

notes = jsonlib.loads(out)
assert isinstance(notes, list)
assert len(notes) == 5
for note in notes:
assert set(note.keys()) == {"level", "asset_id", "text"}
levels = [n["level"] for n in notes]
assert "warning" in levels
assert "info" in levels


def test_inventory_check_strict_json_exit_code(tmp_path, capsys):
inv = tmp_path / "inv.yaml"
inv.write_text(
"schema: patchorder.inventory/v1\nname: t\nassets:\n"
" - {id: a, product: nginx, version: \"1.25.2\", count: 1, owner: o, exposure: internal}\n",
encoding="utf-8",
)
(tmp_path / "inv.lock.yaml").write_text(
"schema: patchorder.lock/v1\nentries:\n a:\n cpe: cpe:2.3:a:f5:nginx\n pinned: true\n origin: user\n",
encoding="utf-8",
)
assert main(["inventory", "check", str(inv), "--json", "--strict"]) == 0
inv.write_text(
"schema: patchorder.inventory/v1\nname: t\nassets:\n"
" - {id: a, product: Example Thing, version: \"1.0\", count: 0, owner: o, exposure: internal}\n"
" - {id: b, product: Example Thing, version: \"1.0\", count: 2, owner: o, exposure: internal}\n",
encoding="utf-8",
)
(tmp_path / "inv.lock.yaml").write_text(
"schema: patchorder.lock/v1\nentries:\n gone:\n cpe: cpe:2.3:a:example:thing\n pinned: true\n origin: user\n",
encoding="utf-8",
)
assert main(["inventory", "check", str(inv), "--json", "--strict"]) == 4
Loading