From ce69d93586ec21883252d41e00f5bbcb009f79b6 Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Wed, 1 Jul 2026 10:44:37 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=EF=BB=BFfeat(cli):=20add=20vouch=20new=20s?= =?UTF-8?q?caffold=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scaffold typed page and entity proposals from the page-kind registry so authors get correctly-shaped drafts through the normal review gate. Co-authored-by: Cursor --- CHANGELOG.md | 5 + src/vouch/cli.py | 232 ++++++++++++++++++++++++++++++++++++- tests/test_new_scaffold.py | 158 +++++++++++++++++++++++++ 3 files changed, 391 insertions(+), 4 deletions(-) create mode 100644 tests/test_new_scaffold.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b03d5c95..48740def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,11 @@ All notable changes to vouch are documented here. Format follows strategy) instead of skipping it, so the capture / recall hooks land on projects that already have a settings file. idempotent; user entries are preserved. +- `vouch new ` — scaffold a typed page or entity proposal from the + page-kind registry: stubs required frontmatter fields, supports + `--field key=value`, `--interactive`, `--dry-run`, and `--json`; entity + kinds (`person`, `project`, …) route to `propose_entity`, with page kinds + taking precedence on name collisions unless `--entity` is set (#330). - GitHub PR auto-labeling: a pull-request metadata-only labeler workflow now applies vouch surface labels from `.github/labeler.yml`, keeps those labels in sync as files change, and adds OpenClaw-style `size: XS` through diff --git a/src/vouch/cli.py b/src/vouch/cli.py index f8b4a0c8..4a7c2f2d 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -16,7 +16,7 @@ from dataclasses import asdict from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, Literal import click import yaml @@ -1018,8 +1018,8 @@ def propose_page_cmd( click.echo(pr.id) -def _parse_meta(pairs: tuple[str, ...]) -> dict[str, Any]: - """Parse repeated ``--meta key=value`` pairs into a frontmatter dict. +def _parse_meta(pairs: tuple[str, ...], *, flag: str = "--meta") -> dict[str, Any]: + """Parse repeated ``key=value`` pairs into a frontmatter dict. Values run through ``yaml.safe_load`` so ``attendees=[a, b]`` and ``count=3`` arrive as a list / int rather than strings. @@ -1027,12 +1027,236 @@ def _parse_meta(pairs: tuple[str, ...]) -> dict[str, Any]: out: dict[str, Any] = {} for pair in pairs: if "=" not in pair: - raise click.BadParameter(f"--meta expects key=value, got {pair!r}") + raise click.BadParameter(f"{flag} expects key=value, got {pair!r}") key, _, raw = pair.partition("=") out[key.strip()] = yaml.safe_load(raw) return out +# Entity kinds `vouch new` can scaffold (issue #330). +_SCAFFOLD_ENTITY_TYPES: frozenset[str] = frozenset( + {"person", "project", "repo", "company", "concept", "decision", "workflow"} +) + +_CITATION_REMINDER = ( + "\n\n\n" +) + + +def _field_missing(value: Any) -> bool: + return value is None or value == "" or value == [] or value == {} + + +def _resolve_new_kind( + kind: str, + registry: Any, + *, + force_entity: bool, +) -> tuple[Literal["page", "entity"], str]: + if force_entity: + if kind not in _SCAFFOLD_ENTITY_TYPES: + known = ", ".join(sorted(_SCAFFOLD_ENTITY_TYPES)) + raise click.ClickException(f"unknown entity type {kind!r} (known: {known})") + return "entity", kind + if registry.is_known(kind): + return "page", kind + if kind in _SCAFFOLD_ENTITY_TYPES: + return "entity", kind + page_kinds = ", ".join(sorted(registry.known())) + entity_kinds = ", ".join(sorted(_SCAFFOLD_ENTITY_TYPES)) + raise click.ClickException( + f"unknown kind {kind!r}; page kinds: {page_kinds}; entity kinds: {entity_kinds}" + ) + + +def _stub_page_frontmatter( + registry: Any, + kind: str, + prefilled: dict[str, Any], +) -> tuple[dict[str, Any], list[str], bool]: + required, _schema, required_citations = registry.resolve(kind) + metadata = dict(prefilled) + for field in required: + metadata.setdefault(field, "") + missing = [f for f in required if _field_missing(metadata.get(f))] + return metadata, missing, required_citations + + +def _prompt_missing_fields( + missing: list[str], + metadata: dict[str, Any], +) -> list[str]: + still_missing: list[str] = [] + for field in missing: + raw = click.prompt(field, default="", show_default=False) + metadata[field] = yaml.safe_load(raw) if raw else "" + if _field_missing(metadata.get(field)): + still_missing.append(field) + return still_missing + + +def _print_new_page_draft(draft: dict[str, Any]) -> None: + click.echo(f"kind: {draft['kind']} (page)") + click.echo(f"title: {draft['title']}") + click.echo(f"frontmatter: {json.dumps(draft['frontmatter'], sort_keys=True)}") + missing = draft["missing_required_fields"] + if missing: + click.echo(f"missing required fields: {', '.join(missing)}") + else: + click.echo("missing required fields: (none)") + if draft["citation_reminder"]: + click.echo("citations: required (reminder appended to body)") + if draft.get("body"): + click.echo(f"body:\n{draft['body']}") + if draft.get("proposal_id"): + click.echo(f"proposal id (dry-run): {draft['proposal_id']}") + + +def _print_new_entity_draft(draft: dict[str, Any]) -> None: + click.echo(f"kind: {draft['kind']} (entity)") + click.echo(f"name: {draft['name']}") + if draft.get("proposal_id"): + click.echo(f"proposal id (dry-run): {draft['proposal_id']}") + + +@cli.command(name="new") +@click.argument("kind") +@click.option("--title", default=None, help="Page title (required for page kinds).") +@click.option("--name", default=None, help="Entity name (required for entity kinds).") +@click.option( + "--field", + "fields", + multiple=True, + help="Pre-fill a frontmatter field as key=value (repeatable). Value parsed as YAML.", +) +@click.option("--interactive", "-i", is_flag=True, help="Prompt for unfilled required fields.") +@click.option("--body", default="", help="Page body. Use `-` to read from stdin.") +@click.option("--claim", "claims", multiple=True) +@click.option("--source", "sources", multiple=True) +@click.option("--entity", "force_entity", is_flag=True, help="Force entity scaffold path.") +@click.option("--dry-run", is_flag=True, help="Print assembled draft without creating a proposal.") +@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.") +def new_cmd( + kind: str, + title: str | None, + name: str | None, + fields: tuple[str, ...], + interactive: bool, + body: str, + claims: tuple[str, ...], + sources: tuple[str, ...], + force_entity: bool, + dry_run: bool, + as_json: bool, +) -> None: + """Scaffold a typed page or entity proposal from the page-kind registry.""" + store = _load_store() + registry = load_page_kind_registry(store) + target, resolved_kind = _resolve_new_kind(kind, registry, force_entity=force_entity) + + if target == "entity": + if not name or not name.strip(): + raise click.ClickException("--name is required for entity kinds") + draft: dict[str, Any] = { + "dry_run": dry_run, + "target": "entity", + "kind": resolved_kind, + "name": name.strip(), + } + if dry_run: + with _cli_errors(): + pr = propose_entity( + store, + name=name, + entity_type=resolved_kind, + proposed_by=_whoami(), + dry_run=True, + ) + draft["proposal_id"] = pr.id + if as_json: + _emit_json(draft) + else: + _print_new_entity_draft(draft) + return + with _cli_errors(): + pr = propose_entity( + store, + name=name, + entity_type=resolved_kind, + proposed_by=_whoami(), + ) + if as_json: + _emit_json({"id": pr.id}) + return + click.echo(pr.id) + return + + if not title or not title.strip(): + raise click.ClickException("--title is required for page kinds") + if body == "-": + body = sys.stdin.read() + + metadata = _parse_meta(fields, flag="--field") + metadata, missing, requires_citations = _stub_page_frontmatter( + registry, resolved_kind, metadata, + ) + if interactive and missing: + missing = _prompt_missing_fields(missing, metadata) + + citation_reminder = requires_citations and not (claims or sources) + if citation_reminder: + body = body + _CITATION_REMINDER + + page_draft: dict[str, Any] = { + "dry_run": dry_run, + "target": "page", + "kind": resolved_kind, + "title": title.strip(), + "frontmatter": metadata, + "body": body, + "missing_required_fields": missing, + "citation_reminder": citation_reminder, + } + + if dry_run: + if not missing and not (requires_citations and not (claims or sources)): + with _cli_errors(): + pr = propose_page( + store, + title=title, + body=body, + page_type=resolved_kind, + claim_ids=list(claims), + source_ids=list(sources), + metadata=metadata, + proposed_by=_whoami(), + dry_run=True, + ) + page_draft["proposal_id"] = pr.id + if as_json: + _emit_json(page_draft) + else: + _print_new_page_draft(page_draft) + return + + with _cli_errors(): + pr = propose_page( + store, + title=title, + body=body, + page_type=resolved_kind, + claim_ids=list(claims), + source_ids=list(sources), + metadata=metadata, + proposed_by=_whoami(), + ) + if as_json: + _emit_json({"id": pr.id}) + return + click.echo(pr.id) + + @cli.group(name="schema") def schema() -> None: """inspect and validate config-declared page kinds (issue #234).""" diff --git a/tests/test_new_scaffold.py b/tests/test_new_scaffold.py new file mode 100644 index 00000000..cc658d8a --- /dev/null +++ b/tests/test_new_scaffold.py @@ -0,0 +1,158 @@ +"""`vouch new` — scaffold typed page/entity proposals (issue #330).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +import yaml +from click.testing import CliRunner + +from vouch.cli import cli +from vouch.models import ProposalKind, ProposalStatus +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + s = KBStore.init(tmp_path) + monkeypatch.chdir(s.root) + return s + + +def _declare_kinds(store: KBStore, kinds: dict[str, Any]) -> None: + cfg = yaml.safe_load(store.config_path.read_text()) + assert isinstance(cfg, dict) + cfg["page_kinds"] = kinds + store.config_path.write_text(yaml.safe_dump(cfg)) + + +def test_new_decision_page_stubs_frontmatter(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "decision", "--title", "pick X"]) + assert res.exit_code == 0, res.output + proposal_id = res.output.strip() + pr = store.get_proposal(proposal_id) + assert pr.kind == ProposalKind.PAGE + assert pr.status == ProposalStatus.PENDING + assert pr.payload["type"] == "decision" + assert pr.payload["title"] == "pick X" + assert pr.payload["metadata"] == {} + + +def test_new_person_entity(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "person", "--name", "alice-example"]) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.ENTITY + assert pr.payload["type"] == "person" + assert pr.payload["name"] == "alice-example" + + +def test_new_field_prefill_parsed_as_yaml(store: KBStore) -> None: + _declare_kinds(store, {"meeting-notes": {"required_fields": ["attendees", "date"]}}) + runner = CliRunner() + res = runner.invoke( + cli, + [ + "new", + "meeting-notes", + "--title", + "Sync", + "--field", + "attendees=[alice-example, bob-example]", + "--field", + "date=2026-07-01", + ], + ) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.payload["metadata"]["attendees"] == ["alice-example", "bob-example"] + assert pr.payload["metadata"]["date"] == "2026-07-01" + + +def test_new_dry_run_writes_nothing(store: KBStore) -> None: + _declare_kinds(store, {"meeting-notes": {"required_fields": ["attendees"]}}) + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "meeting-notes", "--title", "Sync", "--dry-run"], + ) + assert res.exit_code == 0, res.output + assert "missing required fields: attendees" in res.output + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +def test_new_dry_run_json(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "decision", "--title", "pick X", "--dry-run", "--json"], + ) + assert res.exit_code == 0, res.output + body = json.loads(res.output) + assert body["dry_run"] is True + assert body["target"] == "page" + assert body["kind"] == "decision" + assert body["title"] == "pick X" + assert "proposal_id" in body + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +def test_new_collision_decision_defaults_to_page(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "decision", "--title", "pick Y"]) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.PAGE + + +def test_new_collision_decision_entity_flag(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "decision", "--entity", "--name", "pick-z"], + ) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.ENTITY + assert pr.payload["type"] == "decision" + + +def test_new_project_routes_to_entity(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "project", "--name", "vouch-example"]) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.ENTITY + assert pr.payload["type"] == "project" + + +def test_new_unknown_kind_lists_known(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "not-a-kind", "--title", "X"]) + assert res.exit_code != 0, res.output + assert "unknown kind" in res.output + assert "decision" in res.output + assert "person" in res.output + + +def test_new_required_citations_reminder_in_body(store: KBStore) -> None: + _declare_kinds(store, {"cited": {"required_citations": True}}) + runner = CliRunner() + res = runner.invoke(cli, ["new", "cited", "--title", "needs cites", "--dry-run"]) + assert res.exit_code == 0, res.output + assert "citations required" in res.output + assert "citation_reminder" not in res.output or "citations: required" in res.output + + +def test_new_pending_not_approved(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "concept", "--title", "draft page"]) + assert res.exit_code == 0, res.output + assert len(store.list_pages()) == 0 + pending = store.list_proposals(ProposalStatus.PENDING) + assert len(pending) == 1 From 079b94effa452c9382d6824ae1ef72cf329c7ec6 Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Wed, 1 Jul 2026 12:21:29 -0500 Subject: [PATCH 2/2] fix(cli): address pr #333 review feedback catch invalid yaml in --field and interactive prompts, gate citation-required kinds before filing, unify dry-run json id key, and move new command tests into test_cli.py. Co-authored-by: Cursor --- src/vouch/cli.py | 41 ++++++--- tests/test_cli.py | 177 ++++++++++++++++++++++++++++++++++++- tests/test_new_scaffold.py | 158 --------------------------------- 3 files changed, 207 insertions(+), 169 deletions(-) delete mode 100644 tests/test_new_scaffold.py diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 4a7c2f2d..7f47bf6a 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -1029,11 +1029,18 @@ def _parse_meta(pairs: tuple[str, ...], *, flag: str = "--meta") -> dict[str, An if "=" not in pair: raise click.BadParameter(f"{flag} expects key=value, got {pair!r}") key, _, raw = pair.partition("=") - out[key.strip()] = yaml.safe_load(raw) + key = key.strip() + try: + out[key] = yaml.safe_load(raw) + except yaml.YAMLError as e: + raise click.BadParameter( + f"{flag} value for {key!r} is invalid YAML: {e}", + ) from e return out -# Entity kinds `vouch new` can scaffold (issue #330). +# Keep entity scaffolding intentionally narrow because `propose_entity` accepts +# arbitrary type strings; only well-known EntityType names are routed here. _SCAFFOLD_ENTITY_TYPES: frozenset[str] = frozenset( {"person", "project", "repo", "company", "concept", "decision", "workflow"} ) @@ -1090,7 +1097,15 @@ def _prompt_missing_fields( still_missing: list[str] = [] for field in missing: raw = click.prompt(field, default="", show_default=False) - metadata[field] = yaml.safe_load(raw) if raw else "" + if raw: + try: + metadata[field] = yaml.safe_load(raw) + except yaml.YAMLError as e: + raise click.BadParameter( + f"interactive value for {field!r} is invalid YAML: {e}", + ) from e + else: + metadata[field] = "" if _field_missing(metadata.get(field)): still_missing.append(field) return still_missing @@ -1099,7 +1114,8 @@ def _prompt_missing_fields( def _print_new_page_draft(draft: dict[str, Any]) -> None: click.echo(f"kind: {draft['kind']} (page)") click.echo(f"title: {draft['title']}") - click.echo(f"frontmatter: {json.dumps(draft['frontmatter'], sort_keys=True)}") + fm = yaml.safe_dump(draft["frontmatter"], default_flow_style=True).strip() + click.echo(f"frontmatter: {fm}") missing = draft["missing_required_fields"] if missing: click.echo(f"missing required fields: {', '.join(missing)}") @@ -1109,15 +1125,15 @@ def _print_new_page_draft(draft: dict[str, Any]) -> None: click.echo("citations: required (reminder appended to body)") if draft.get("body"): click.echo(f"body:\n{draft['body']}") - if draft.get("proposal_id"): - click.echo(f"proposal id (dry-run): {draft['proposal_id']}") + if draft.get("id"): + click.echo(f"proposal id (dry-run): {draft['id']}") def _print_new_entity_draft(draft: dict[str, Any]) -> None: click.echo(f"kind: {draft['kind']} (entity)") click.echo(f"name: {draft['name']}") - if draft.get("proposal_id"): - click.echo(f"proposal id (dry-run): {draft['proposal_id']}") + if draft.get("id"): + click.echo(f"proposal id (dry-run): {draft['id']}") @cli.command(name="new") @@ -1173,7 +1189,7 @@ def new_cmd( proposed_by=_whoami(), dry_run=True, ) - draft["proposal_id"] = pr.id + draft["id"] = pr.id if as_json: _emit_json(draft) else: @@ -1205,6 +1221,11 @@ def new_cmd( missing = _prompt_missing_fields(missing, metadata) citation_reminder = requires_citations and not (claims or sources) + if citation_reminder and not dry_run: + raise click.ClickException( + "this page kind requires citations; pass --claim/--source, " + "or rerun with --dry-run to print a draft with the citation reminder" + ) if citation_reminder: body = body + _CITATION_REMINDER @@ -1233,7 +1254,7 @@ def new_cmd( proposed_by=_whoami(), dry_run=True, ) - page_draft["proposal_id"] = pr.id + page_draft["id"] = pr.id if as_json: _emit_json(page_draft) else: diff --git a/tests/test_cli.py b/tests/test_cli.py index 73901b00..50f8841e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,14 +12,16 @@ import json from pathlib import Path +from typing import Any from unittest.mock import patch import pytest +import yaml from click.testing import CliRunner from vouch import sessions as sess_mod from vouch.cli import cli -from vouch.models import ProposalStatus +from vouch.models import ProposalKind, ProposalStatus from vouch.proposals import propose_claim from vouch.storage import KBStore @@ -427,3 +429,176 @@ def test_propose_claim_corrupt_source_surfaces_real_error(store: KBStore) -> Non isinstance(excinfo.value, ProposalError) and "unknown source/evidence id" in str(excinfo.value) ), f"real parse error was masked: {excinfo.value}" + + +# --- vouch new (issue #330) ------------------------------------------------ + + +def _declare_page_kinds(store: KBStore, kinds: dict[str, Any]) -> None: + cfg = yaml.safe_load(store.config_path.read_text()) + assert isinstance(cfg, dict) + cfg["page_kinds"] = kinds + store.config_path.write_text(yaml.safe_dump(cfg)) + + +def test_new_decision_page_stubs_frontmatter(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "decision", "--title", "pick X"]) + assert res.exit_code == 0, res.output + proposal_id = res.output.strip() + pr = store.get_proposal(proposal_id) + assert pr.kind == ProposalKind.PAGE + assert pr.status == ProposalStatus.PENDING + assert pr.payload["type"] == "decision" + assert pr.payload["title"] == "pick X" + assert pr.payload["metadata"] == {} + + +def test_new_person_entity(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "person", "--name", "alice-example"]) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.ENTITY + assert pr.payload["type"] == "person" + assert pr.payload["name"] == "alice-example" + + +def test_new_field_prefill_parsed_as_yaml(store: KBStore) -> None: + _declare_page_kinds(store, {"meeting-notes": {"required_fields": ["attendees", "date"]}}) + runner = CliRunner() + res = runner.invoke( + cli, + [ + "new", + "meeting-notes", + "--title", + "Sync", + "--field", + "attendees=[alice-example, bob-example]", + "--field", + "date=2026-07-01", + ], + ) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.payload["metadata"]["attendees"] == ["alice-example", "bob-example"] + assert pr.payload["metadata"]["date"] == "2026-07-01" + + +def test_new_interactive_prompts_required_fields(store: KBStore) -> None: + _declare_page_kinds(store, {"meeting-notes": {"required_fields": ["attendees", "date"]}}) + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "meeting-notes", "--title", "Sync", "--interactive"], + input="[alice-example, bob-example]\n2026-07-01\n", + ) + assert res.exit_code == 0, res.output + pending = store.list_proposals(ProposalStatus.PENDING) + assert len(pending) == 1 + pr = pending[0] + assert pr.payload["metadata"]["attendees"] == ["alice-example", "bob-example"] + assert pr.payload["metadata"]["date"] == "2026-07-01" + + +def test_new_dry_run_writes_nothing(store: KBStore) -> None: + _declare_page_kinds(store, {"meeting-notes": {"required_fields": ["attendees"]}}) + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "meeting-notes", "--title", "Sync", "--dry-run"], + ) + assert res.exit_code == 0, res.output + assert "missing required fields: attendees" in res.output + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +def test_new_dry_run_json(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "decision", "--title", "pick X", "--dry-run", "--json"], + ) + assert res.exit_code == 0, res.output + body = json.loads(res.output) + assert body["dry_run"] is True + assert body["target"] == "page" + assert body["kind"] == "decision" + assert body["title"] == "pick X" + assert "id" in body + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +def test_new_collision_decision_defaults_to_page(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "decision", "--title", "pick Y"]) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.PAGE + + +def test_new_collision_decision_entity_flag(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "decision", "--entity", "--name", "pick-z"], + ) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.ENTITY + assert pr.payload["type"] == "decision" + + +def test_new_project_routes_to_entity(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "project", "--name", "vouch-example"]) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.ENTITY + assert pr.payload["type"] == "project" + + +def test_new_unknown_kind_lists_known(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "not-a-kind", "--title", "X"]) + assert res.exit_code != 0, res.output + assert "unknown kind" in res.output + assert "decision" in res.output + assert "person" in res.output + + +def test_new_required_citations_reminder_in_body(store: KBStore) -> None: + _declare_page_kinds(store, {"cited": {"required_citations": True}}) + runner = CliRunner() + res = runner.invoke(cli, ["new", "cited", "--title", "needs cites", "--dry-run"]) + assert res.exit_code == 0, res.output + assert "citations required" in res.output + assert "attach at least one claim or source" in res.output + + +def test_new_required_citations_non_dry_run_errors(store: KBStore) -> None: + _declare_page_kinds(store, {"cited": {"required_citations": True}}) + runner = CliRunner() + res = runner.invoke(cli, ["new", "cited", "--title", "needs cites"]) + assert res.exit_code != 0, res.output + assert "requires citations" in res.output + + +def test_new_invalid_field_yaml(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "decision", "--title", "X", "--field", "x=["], + ) + assert res.exit_code != 0, res.output + assert "invalid YAML" in res.output + + +def test_new_pending_not_approved(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "concept", "--title", "draft page"]) + assert res.exit_code == 0, res.output + assert len(store.list_pages()) == 0 + pending = store.list_proposals(ProposalStatus.PENDING) + assert len(pending) == 1 diff --git a/tests/test_new_scaffold.py b/tests/test_new_scaffold.py deleted file mode 100644 index cc658d8a..00000000 --- a/tests/test_new_scaffold.py +++ /dev/null @@ -1,158 +0,0 @@ -"""`vouch new` — scaffold typed page/entity proposals (issue #330).""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -import pytest -import yaml -from click.testing import CliRunner - -from vouch.cli import cli -from vouch.models import ProposalKind, ProposalStatus -from vouch.storage import KBStore - - -@pytest.fixture -def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: - s = KBStore.init(tmp_path) - monkeypatch.chdir(s.root) - return s - - -def _declare_kinds(store: KBStore, kinds: dict[str, Any]) -> None: - cfg = yaml.safe_load(store.config_path.read_text()) - assert isinstance(cfg, dict) - cfg["page_kinds"] = kinds - store.config_path.write_text(yaml.safe_dump(cfg)) - - -def test_new_decision_page_stubs_frontmatter(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "decision", "--title", "pick X"]) - assert res.exit_code == 0, res.output - proposal_id = res.output.strip() - pr = store.get_proposal(proposal_id) - assert pr.kind == ProposalKind.PAGE - assert pr.status == ProposalStatus.PENDING - assert pr.payload["type"] == "decision" - assert pr.payload["title"] == "pick X" - assert pr.payload["metadata"] == {} - - -def test_new_person_entity(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "person", "--name", "alice-example"]) - assert res.exit_code == 0, res.output - pr = store.get_proposal(res.output.strip()) - assert pr.kind == ProposalKind.ENTITY - assert pr.payload["type"] == "person" - assert pr.payload["name"] == "alice-example" - - -def test_new_field_prefill_parsed_as_yaml(store: KBStore) -> None: - _declare_kinds(store, {"meeting-notes": {"required_fields": ["attendees", "date"]}}) - runner = CliRunner() - res = runner.invoke( - cli, - [ - "new", - "meeting-notes", - "--title", - "Sync", - "--field", - "attendees=[alice-example, bob-example]", - "--field", - "date=2026-07-01", - ], - ) - assert res.exit_code == 0, res.output - pr = store.get_proposal(res.output.strip()) - assert pr.payload["metadata"]["attendees"] == ["alice-example", "bob-example"] - assert pr.payload["metadata"]["date"] == "2026-07-01" - - -def test_new_dry_run_writes_nothing(store: KBStore) -> None: - _declare_kinds(store, {"meeting-notes": {"required_fields": ["attendees"]}}) - runner = CliRunner() - res = runner.invoke( - cli, - ["new", "meeting-notes", "--title", "Sync", "--dry-run"], - ) - assert res.exit_code == 0, res.output - assert "missing required fields: attendees" in res.output - assert store.list_proposals(ProposalStatus.PENDING) == [] - - -def test_new_dry_run_json(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke( - cli, - ["new", "decision", "--title", "pick X", "--dry-run", "--json"], - ) - assert res.exit_code == 0, res.output - body = json.loads(res.output) - assert body["dry_run"] is True - assert body["target"] == "page" - assert body["kind"] == "decision" - assert body["title"] == "pick X" - assert "proposal_id" in body - assert store.list_proposals(ProposalStatus.PENDING) == [] - - -def test_new_collision_decision_defaults_to_page(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "decision", "--title", "pick Y"]) - assert res.exit_code == 0, res.output - pr = store.get_proposal(res.output.strip()) - assert pr.kind == ProposalKind.PAGE - - -def test_new_collision_decision_entity_flag(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke( - cli, - ["new", "decision", "--entity", "--name", "pick-z"], - ) - assert res.exit_code == 0, res.output - pr = store.get_proposal(res.output.strip()) - assert pr.kind == ProposalKind.ENTITY - assert pr.payload["type"] == "decision" - - -def test_new_project_routes_to_entity(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "project", "--name", "vouch-example"]) - assert res.exit_code == 0, res.output - pr = store.get_proposal(res.output.strip()) - assert pr.kind == ProposalKind.ENTITY - assert pr.payload["type"] == "project" - - -def test_new_unknown_kind_lists_known(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "not-a-kind", "--title", "X"]) - assert res.exit_code != 0, res.output - assert "unknown kind" in res.output - assert "decision" in res.output - assert "person" in res.output - - -def test_new_required_citations_reminder_in_body(store: KBStore) -> None: - _declare_kinds(store, {"cited": {"required_citations": True}}) - runner = CliRunner() - res = runner.invoke(cli, ["new", "cited", "--title", "needs cites", "--dry-run"]) - assert res.exit_code == 0, res.output - assert "citations required" in res.output - assert "citation_reminder" not in res.output or "citations: required" in res.output - - -def test_new_pending_not_approved(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "concept", "--title", "draft page"]) - assert res.exit_code == 0, res.output - assert len(store.list_pages()) == 0 - pending = store.list_proposals(ProposalStatus.PENDING) - assert len(pending) == 1