diff --git a/src/patchorder/cli.py b/src/patchorder/cli.py index 0a45108..b5d9430 100644 --- a/src/patchorder/cli.py +++ b/src/patchorder/cli.py @@ -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) @@ -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 diff --git a/src/patchorder/inventorytools.py b/src/patchorder/inventorytools.py index 9469a3b..da06c0b 100644 --- a/src/patchorder/inventorytools.py +++ b/src/patchorder/inventorytools.py @@ -5,7 +5,7 @@ import csv import io import json -from dataclasses import dataclass +from dataclasses import asdict, dataclass from pathlib import Path @@ -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) diff --git a/tests/test_v02_gate_csv_inventory.py b/tests/test_v02_gate_csv_inventory.py index 763082a..b5cce52 100644 --- a/tests/test_v02_gate_csv_inventory.py +++ b/tests/test_v02_gate_csv_inventory.py @@ -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