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
185 changes: 184 additions & 1 deletion src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from .context import build_context_pack
from .lifecycle import LifecycleError
from .logging_config import configure_logging
from .models import Proposal, ProposalKind, ProposalStatus
from .models import EntityType, Proposal, ProposalKind, ProposalStatus
from .onboarding import seed_starter_kb
from .page_kinds import PageKindError, load_page_kind_registry
from .proposals import (
Expand Down Expand Up @@ -1033,6 +1033,189 @@ def _parse_meta(pairs: tuple[str, ...]) -> dict[str, Any]:
return out


@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(
"--entity",
"force_entity",
is_flag=True,
help="force the entity path for a name that is also a page kind.",
)
@click.option(
"--field",
"fields",
multiple=True,
help="pre-fill a frontmatter field as key=value (repeatable). "
"Value parsed as YAML, mirroring --meta on propose-page.",
)
@click.option("--body", default="", help="page body. Use `-` to read from stdin.")
@click.option(
"-i",
"--interactive",
is_flag=True,
help="prompt for each unfilled required field (default off, so runs stay scriptable).",
)
@click.option(
"--dry-run",
"dry_run",
is_flag=True,
help="print the assembled draft and its missing required fields; create no proposal.",
)
@click.option(
"--json", "as_json", is_flag=True, help="emit the proposal id (or dry-run draft) as JSON."
)
@click.option("--claim", "claims", multiple=True, help="claim id to cite (page kinds).")
@click.option("--source", "sources", multiple=True, help="source id to cite (page kinds).")
@click.option("--alias", "aliases", multiple=True, help="entity alias (entity kinds).")
@click.option("--description", default=None, help="entity description (entity kinds).")
def new_cmd(
kind: str,
title: str | None,
name: str | None,
force_entity: bool,
fields: tuple[str, ...],
body: str,
interactive: bool,
dry_run: bool,
as_json: bool,
claims: tuple[str, ...],
sources: tuple[str, ...],
aliases: tuple[str, ...],
description: str | None,
) -> None:
"""Scaffold a typed page or entity proposal from its registered shape.

Reads the page-kind registry (or ``EntityType``) for KIND, stubs every
required field, and files a *pending* proposal through the normal review
gate exactly as ``propose-page`` / ``propose-entity`` do. A human still
runs ``vouch approve <id>`` to land it; the scaffold never writes an
approved artifact and never weakens validation (``propose_page``
re-validates the kind, so an unfilled required field is flagged the same
way any other proposal is).
"""
store = _load_store()
with _cli_errors():
registry = load_page_kind_registry(store)
page_kinds = registry.known()
entity_kinds = {e.value for e in EntityType}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Deterministic page/entity dispatch. Some names (``decision``, ``project``)
# are both a page kind and an EntityType member: a registered page kind
# scaffolds a page; ``--entity`` forces the entity path; a name that is only
# an EntityType scaffolds an entity; anything else fails with the known set.
if force_entity:
if kind not in entity_kinds:
raise click.BadParameter(
f"--entity given, but {kind!r} is not an entity type "
f"(known: {', '.join(sorted(entity_kinds))})."
)
target = "entity"
elif kind in page_kinds:
target = "page"
elif kind in entity_kinds:
target = "entity"
else:
raise click.BadParameter(
f"unknown kind {kind!r}. page kinds: {', '.join(sorted(page_kinds))}; "
f"entity types: {', '.join(sorted(entity_kinds))}."
)

if target == "entity":
if not name:
raise click.BadParameter(f"entity kind {kind!r} needs --name.")
if dry_run:
if as_json:
_emit_json(
{
"target": "entity",
"kind": kind,
"name": name,
"aliases": list(aliases),
"description": description,
}
)
else:
click.echo(f"[dry-run] entity {kind}: name={name!r}")
if aliases:
click.echo(f" aliases: {', '.join(aliases)}")
return
with _cli_errors():
pr = propose_entity(
store,
name=name,
entity_type=kind,
aliases=list(aliases),
description=description,
proposed_by=_whoami(),
)
if as_json:
_emit_json({"id": pr.id})
else:
click.echo(pr.id)
return

# --- page path ---
if not title:
raise click.BadParameter(f"page kind {kind!r} needs --title.")
if body == "-":
body = sys.stdin.read()
with _cli_errors():
required, _schema, requires_citations = registry.resolve(kind)
metadata = _parse_meta(fields)
for field in required:
if field in metadata:
continue
if interactive:
raw = click.prompt(field, default="", show_default=False)
metadata[field] = yaml.safe_load(raw) if raw != "" else ""
else:
metadata[field] = ""
missing = [f for f in required if metadata.get(f) in (None, "", [], {})]
cite_reminder = bool(requires_citations) and not (claims or sources)

if dry_run:
if as_json:
_emit_json(
{
"target": "page",
"kind": kind,
"title": title,
"frontmatter": metadata,
"missing_required": missing,
"citations_required": bool(requires_citations),
}
)
else:
click.echo(f"[dry-run] page {kind}: {title!r}")
for key, value in metadata.items():
click.echo(f" {key}: {value!r}")
if missing:
click.echo(f" missing required: {', '.join(missing)}")
if cite_reminder:
click.echo(" reminder: this kind requires citations - add --claim/--source.")
return

if cite_reminder:
click.echo("reminder: this kind requires citations - add --claim/--source.", err=True)
with _cli_errors():
pr = propose_page(
store,
title=title,
body=body,
page_type=kind,
claim_ids=list(claims),
source_ids=list(sources),
metadata=metadata,
proposed_by=_whoami(),
)
if as_json:
_emit_json({"id": pr.id})
else:
click.echo(pr.id)


@cli.group(name="schema")
def schema() -> None:
"""inspect and validate config-declared page kinds (issue #234)."""
Expand Down
125 changes: 125 additions & 0 deletions tests/test_new_scaffold.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""``vouch new <kind>`` — scaffold a typed page/entity proposal (issue #330).

The scaffold reads the page-kind registry (or ``EntityType``) for the kind,
stubs every required field, and files a *pending* proposal through the normal
review gate. It reuses ``propose_page`` / ``propose_entity`` verbatim, so it
never writes an approved artifact and never weakens validation: an unfilled
required field is flagged the same way any other proposal is. ``--dry-run``
shows the stubbed shape (and the missing-field list) without filing anything.
"""

from __future__ import annotations

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_kind(store: KBStore, name: str, **spec: Any) -> None:
"""Write a ``config.yaml`` ``page_kinds.<name>`` entry for the test kb."""
cfg = store.kb_dir / "config.yaml"
data = yaml.safe_load(cfg.read_text(encoding="utf-8")) if cfg.exists() else {}
data = data or {}
data.setdefault("page_kinds", {})[name] = spec
cfg.write_text(yaml.safe_dump(data), encoding="utf-8")


def test_page_scaffold_creates_pending_with_required_fields(store: KBStore) -> None:
_declare_kind(store, "decision-record", required_fields=["status", "owner"])
r = CliRunner().invoke(
cli,
[
"new", "decision-record", "--title", "pick db",
"--field", "status=open", "--field", "owner=alice",
],
)
assert r.exit_code == 0, r.output
pr = store.get_proposal(r.output.strip())
assert pr.status == ProposalStatus.PENDING
assert pr.kind == ProposalKind.PAGE
md = pr.payload["metadata"]
assert md["status"] == "open"
assert md["owner"] == "alice"


def test_dry_run_stubs_required_fields_and_files_nothing(store: KBStore) -> None:
_declare_kind(store, "decision-record", required_fields=["status", "owner"])
before = len(store.list_proposals())
r = CliRunner().invoke(
cli,
["new", "decision-record", "--title", "x", "--field", "status=open", "--dry-run"],
)
assert r.exit_code == 0, r.output
# every required field is stubbed into the draft; the unfilled one is listed.
assert "status" in r.output and "owner" in r.output
assert "missing required" in r.output
assert len(store.list_proposals()) == before # nothing filed


def test_unfilled_required_field_is_flagged(store: KBStore) -> None:
# propose_page re-validates, so an empty required field is flagged, not
# silently written — the scaffold never weakens validation.
_declare_kind(store, "decision-record", required_fields=["status"])
r = CliRunner().invoke(cli, ["new", "decision-record", "--title", "x"])
assert r.exit_code != 0
assert "Traceback" not in r.output
assert "status" in r.output


def test_field_is_parsed_as_yaml(store: KBStore) -> None:
_declare_kind(store, "meeting", required_fields=["attendees"])
r = CliRunner().invoke(
cli, ["new", "meeting", "--title", "sync", "--field", "attendees=[a, b]"]
)
assert r.exit_code == 0, r.output
pr = store.get_proposal(r.output.strip())
assert pr.payload["metadata"]["attendees"] == ["a", "b"]


def test_entity_scaffold_routes_to_propose_entity(store: KBStore) -> None:
r = CliRunner().invoke(cli, ["new", "person", "--name", "alice-example"])
assert r.exit_code == 0, r.output
pr = store.get_proposal(r.output.strip())
assert pr.kind == ProposalKind.ENTITY
assert pr.status == ProposalStatus.PENDING
assert pr.payload["name"] == "alice-example"


def test_collision_defaults_to_page_and_entity_flag_forces_entity(store: KBStore) -> None:
# ``decision`` is both a built-in page type and an EntityType member.
r_page = CliRunner().invoke(cli, ["new", "decision", "--title", "d1"])
assert r_page.exit_code == 0, r_page.output
assert store.get_proposal(r_page.output.strip()).kind == ProposalKind.PAGE

r_ent = CliRunner().invoke(cli, ["new", "decision", "--entity", "--name", "d-ent"])
assert r_ent.exit_code == 0, r_ent.output
assert store.get_proposal(r_ent.output.strip()).kind == ProposalKind.ENTITY


def test_unknown_kind_errors_with_known_list(store: KBStore) -> None:
r = CliRunner().invoke(cli, ["new", "no-such-kind", "--title", "x"])
assert r.exit_code != 0
assert "unknown kind" in r.output


def test_scaffold_only_files_a_pending_proposal(store: KBStore) -> None:
r = CliRunner().invoke(cli, ["new", "concept", "--title", "graphs"])
assert r.exit_code == 0, r.output
pid = r.output.strip()
assert store.get_proposal(pid).status == ProposalStatus.PENDING
pending_ids = [p.id for p in store.list_proposals(ProposalStatus.PENDING)]
assert pid in pending_ids