git-ents.gitmain
⌘K
foforge
commit 3dc5750
spec: porcelain grammar, presentation derivation, and form parity rules; sync abstractions porcelain listing

Three new requirements legislating the Facet-derived presentation layer: lens.porcelain (one shared --porcelain record grammar for every entity family), model.presentation (presentation policy declared once as ents attributes on the entity’s own shape, consumed generically by every surface), and lens.forms (web forms derived from the same action shapes the CLI grammar parses). Tracey @relation coverage lands on ents-forge::present, ents-attrs, the CLI porcelain tests, compose, and ents-web’s render/form. The abstractions porcelain listing now matches cli.rs’s actual Top surface (issue, review, agent, hook, lsp, bootstrap; corrected account and redact actions; serve instead of the nonexistent server subcommand) and notes the shared --porcelain grammar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Joseph D. Carpinelli · 27 days ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

docs/abstractions.adoc @@ -367,18 +367,26 @@ Porcelain: .... -git ents setup +git ents setup [--hosted] +git ents bootstrap <username> git ents members list|add|remove|revoke|unrevoke|check -git ents account create|link -git ents effect ... -git ents toolchain ... -git ents comment ... +git ents account show|create +git ents effect list|show|add|run|log +git ents toolchain list|import|view|log +git ents comment list|add|reply|resolve|reopen|show +git ents issue list|show|new|edit +git ents review new|withdraw|list|show +git ents agent new|plan|confirm|list|show git ents inbox list|adopt -git ents redact <oid> +git ents redact list|add <oid> git ents login -git ents serve | server +git ents serve [--hosted] +git ents lsp +git ents hook pre-receive|post-receive|reconcile # plumbing for git's own hooks .... +The entity listings (`comment list`, `issue list`, `review list`, `effect list`, `effect log`) take `--porcelain`, emitting one shared machine-readable record grammar — the format `git ents comment list --porcelain` established — rather than a per-family format. + Revocation is a state on the member entity, not deletion — a revoked key must be explicitly rejected, whereas deletion would merely make old signatures unverifiable. '''''
docs/spec/lens.adoc @@ -99,3 +99,37 @@ resolve every open comment in the working tree without any editor attached. -- + +[role="requirement", id="lens.porcelain"] +.One Record Grammar for Every Porcelain +-- +Every entity family's `--porcelain` listing MUST emit the one record +grammar `git ents comment list --porcelain` established +(<<lens.parity>>), never a format of its own: a head line of +space-separated tokens leading with the record's id, one +`<field> <value>` line per remaining field — omitted when the field is +declared hide-when-empty and is empty (<<model.presentation>>) — each +body line prefixed with one tab, and records separated by exactly one +blank line. +A blank body line MUST render as a lone tab, so only the record +separator is ever a true blank line. +Every id in machine-readable output MUST be full, never the abbreviated +form human listings use (<<model.issue>>). +-- + +[role="requirement", id="lens.forms"] +.Web Forms Derive from the Action Shape +-- +A web form for an entity action MUST derive its controls from the same +`#[derive(Facet)]` action shape whose flags the CLI grammar parses — +one field list, declared on the action variant, obeyed by both +frontends — and parsing the posted form MUST produce that same action +shape, dispatched to the same library operation the CLI calls +(<<lens.parity>>). +A field the web cannot supply (a value bound into the route's own path, +a local signing-key path) MUST be excluded by what its declaration +says, never by a per-route field list; an unposted field MUST take the +declaration's own default, exactly as the CLI defaults an omitted flag. +A page MAY override a single field's control (a state picker, a +verdict picker), but the override MUST feed the same parse. +--
docs/spec/model.adoc @@ -21,6 +21,26 @@ to recompile. -- +[role="requirement", id="model.presentation"] +.Presentation Policy Lives on the Shape +-- +An entity's presentation policy — which fields render and in what +order, which lead a listing, which are omitted when empty, which +values are ids (abbreviated for humans, full in machine-readable +output), and which field is the body — MUST be declared once, as +`ents` attributes on the fields of the entity's own `#[derive(Facet)]` +shape (<<model.extensibility>>), and every surface MUST consume it by +walking that shape generically: no surface may re-encode per-entity +presentation policy in its own code, and rendering MUST NOT branch on +the concrete entity type. +A surface MAY append a genuinely domain-specific line — a comment's +projected anchor, a review's thread — beside the generic walk, never +in place of it. +An action field composed in the editor when its flag is omitted MUST +likewise be declared on the action's own shape; the same declaration +selects the web form's long-text control (<<lens.forms>>). +-- + ''''' === Member
crates/cli/ents-web/src/form.rs @@ -44,6 +44,7 @@ /// Render the form `T`'s variant `variant` declares: one derived control /// per web-suppliable field in declaration order, `spec.overrides` /// slotted in place, the session's CSRF hidden field leading. +// @relation(lens.forms, scope=function) #[must_use] pub fn action_form<T: Facet<'static>>(variant: &str, session: &Session, spec: &Spec<'_>) -> Markup { let fields = variant_fields::<T>(variant).unwrap_or_default(); @@ -80,6 +81,7 @@ /// /// [`Error::InvalidArgument`] if `T` has no such variant, a field's shape /// is not one this mapping speaks, or the value cannot be set. +// @relation(lens.forms, scope=function) pub fn parse_action<T: Facet<'static>>(variant: &str, pairs: &[(String, String)]) -> Result<T> { let malformed = |source: &dyn std::fmt::Display| { Error::InvalidArgument(format!("malformed {variant} form: {source}")) @@ -242,7 +244,7 @@ /// field is a textarea, a `Vec` a comma input, a `PathBuf` (`--key`) /// and a positional id never render, and an override slots in place. #[rstest] - // @relation(lens.parity, scope=function, role=Verifies) + // @relation(lens.forms, lens.parity, scope=function, role=Verifies) fn action_form_derives_controls_from_the_variant_shape() { let markup = action_form::<IssueAction>( "New", @@ -282,7 +284,7 @@ /// and whitespace, an empty optional is `None`, the unposted `--key` /// defaults, and unknown pairs (csrf) are ignored. #[rstest] - // @relation(lens.parity, scope=function, role=Verifies) + // @relation(lens.forms, lens.parity, scope=function, role=Verifies) fn parse_action_reads_the_posted_pairs_into_the_variant() { let action: IssueAction = parse_action( "New", @@ -318,7 +320,7 @@ /// An unposted field takes the declaration's own default — the same /// `default = "open"` the CLI applies to an omitted flag. #[rstest] - // @relation(lens.parity, scope=function, role=Verifies) + // @relation(lens.forms, lens.parity, scope=function, role=Verifies) fn parse_action_defaults_an_unposted_field_from_the_declaration() { let action: ReviewAction = parse_action("New", &pairs(&[("verdict", "approve")])).expect("parses");
crates/cli/ents-web/src/render.rs @@ -54,6 +54,7 @@ /// assert_eq!(rows[2].0, "state"); /// assert_eq!(rows[2].1, "active"); /// ``` +// @relation(model.presentation, scope=function) #[must_use] pub fn fields<T: Facet<'static>>(value: &T) -> Vec<FieldRow> { let walked = ents_forge::present::fields(value); @@ -405,7 +406,7 @@ /// refname-bound name, `ents::skip_empty` hides an empty set, and the /// list narrows to the declared `ents::col` columns. #[rstest] - // @relation(roots.web-agnostic, scope=function, role=Verifies) + // @relation(model.presentation, roots.web-agnostic, scope=function, role=Verifies) fn ents_attributes_govern_the_view_and_the_list_columns() { let effect = Effect { name: "unit".to_owned(),
crates/cli/git-ents/src/compose.rs @@ -70,7 +70,9 @@ /// Refuse unless `T`'s variant marks `field` with `ents::compose` — the /// attribute on the action enum, not this module, is what licenses the -/// editor fallback; an unmarked omitted flag is simply a missing argument. +/// editor fallback (`model.presentation`); an unmarked omitted flag is +/// simply a missing argument. +// @relation(model.presentation, scope=function) fn require_compose<T: Facet<'static>>(variant: &str, field: &str) -> Result<()> { let marked = match T::SHAPE.ty { Type::User(UserType::Enum(shape)) => shape
crates/cli/git-ents/tests/comment.rs @@ -190,8 +190,8 @@ /// Two records separate with exactly one blank line, and an unanchored /// reply renders `-` for projection and location — the porcelain grammar -/// an agent parses (`lens.parity`). -// @relation(lens.parity, scope=function, role=Verifies) +/// an agent parses (`lens.porcelain`, `lens.parity`). +// @relation(lens.porcelain, lens.parity, scope=function, role=Verifies) #[test] fn porcelain_separates_records_and_renders_unanchored_comments() { let fixture = common::Fixture::new(1);
crates/cli/git-ents/tests/effect.rs @@ -28,7 +28,7 @@ /// (`lens.parity`): a `<name>` head line, then `trigger`, `toolchains` /// (omitted when empty), and `run` keyed lines, records blank-line /// separated; the human listing stays `<name>\t<trigger>`. -// @relation(lens.parity, model.effect-definition, roots.local, scope=function, role=Verifies) +// @relation(lens.porcelain, lens.parity, model.effect-definition, roots.local, scope=function, role=Verifies) #[test] fn effect_list_porcelain_emits_one_record_per_definition() { let fixture = common::Fixture::new(1); @@ -67,7 +67,7 @@ /// `git ents effect log --porcelain` emits one `<commit> <status>` record /// per judged commit, the commit's full oid (`model.result-identity`, /// `lens.parity`); the human listing abbreviates it like git does. -// @relation(lens.parity, model.result-identity, roots.local, scope=function, role=Verifies) +// @relation(lens.porcelain, lens.parity, model.result-identity, roots.local, scope=function, role=Verifies) #[test] fn effect_log_porcelain_carries_the_full_judged_commit_oid() { let fixture = common::Fixture::new(2);
crates/cli/git-ents/tests/issue.rs @@ -108,7 +108,7 @@ /// (`lens.parity`, `model.issue`): full ids on a space-separated head /// line, `title`/`assignees`/`labels` keyed lines (empties omitted), the /// body tab-prefixed line by line, records blank-line separated. -// @relation(lens.parity, model.issue, roots.local, scope=function, role=Verifies) +// @relation(lens.porcelain, lens.parity, model.issue, roots.local, scope=function, role=Verifies) #[test] fn issue_list_porcelain_emits_full_id_records() { let fixture = common::Fixture::new(5);
crates/cli/git-ents/tests/review.rs @@ -287,7 +287,7 @@ /// (`lens.parity`, `model.review`): a head line of full target segment, /// member, full reviewed oid, verdict, and state, then the body /// tab-prefixed — and a withdrawal shows up as the state token. -// @relation(lens.parity, model.review, roots.local, scope=function, role=Verifies) +// @relation(lens.porcelain, lens.parity, model.review, roots.local, scope=function, role=Verifies) #[test] fn review_list_porcelain_emits_full_id_records() { let fixture = common::Fixture::new(6);
crates/forge/ents-forge/src/present.rs @@ -7,6 +7,7 @@ //! comment's projected anchor, a review's thread) appends it beside this //! module's output rather than reaching into the walk. +// @relation(model.presentation, scope=file) use facet::{Facet, Field, Peek}; /// One rendered field: its declared name and display value. @@ -112,7 +113,7 @@ /// `id` then each `ents::head` field's value, space-separated; one /// `<name> <value>` line per remaining field (omitted when /// `ents::skip_empty` and empty); the `ents::body` field's lines each -/// tab-prefixed. Ids render full, never abbreviated. +/// tab-prefixed. Ids render full, never abbreviated (`lens.porcelain`). /// /// # Examples /// @@ -128,6 +129,7 @@ /// "unit\ntrigger rev(refs/heads/main)\nrun cargo test\n" /// ); /// ``` +// @relation(lens.porcelain, scope=function) #[must_use] pub fn record<T: Facet<'static>>(id: &str, value: &T) -> String { let rows = rows(value, Audience::Porcelain); @@ -197,7 +199,9 @@ } /// [`record`] over every `(id, entity)` row, records separated by one -/// blank line — the whole `--porcelain` output for a listing. +/// blank line — the whole `--porcelain` output for a listing +/// (`lens.porcelain`). +// @relation(lens.porcelain, scope=function) #[must_use] pub fn porcelain<T: Facet<'static>>(rows: &[(String, T)]) -> String { rows.iter() @@ -363,7 +367,7 @@ /// an issue and a comment with each one's own field policy, no branch /// on the concrete type anywhere in this module. #[rstest] - // @relation(model.issue, model.comment, scope=function, role=Verifies) + // @relation(model.presentation, model.issue, model.comment, scope=function, role=Verifies) fn view_orders_lines_by_declaration_with_the_body_last_and_empties_skipped() { let rendered = view(&issue()).to_string(); assert_eq!( @@ -387,7 +391,7 @@ /// `model.issue`: human columns abbreviate id-valued fields the way /// git abbreviates oids; head columns lead, then plain columns. #[rstest] - // @relation(model.issue, model.review, scope=function, role=Verifies) + // @relation(model.presentation, model.issue, model.review, scope=function, role=Verifies) fn columns_lead_with_head_fields_and_abbreviate_ids() { assert_eq!( columns(&issue()), @@ -410,7 +414,7 @@ /// id and full field values on a space-separated head line, keyed /// lines for the rest, and the body tab-prefixed line by line. #[rstest] - // @relation(lens.parity, model.issue, model.review, scope=function, role=Verifies) + // @relation(lens.porcelain, lens.parity, model.issue, model.review, scope=function, role=Verifies) fn record_renders_full_ids_keyed_lines_and_a_tab_prefixed_body() { let id = "89abcdef0123456789abcdef0123456789abcdef"; assert_eq!( @@ -429,7 +433,7 @@ /// Records separate with exactly one blank line, mirroring the comment /// porcelain grammar. #[rstest] - // @relation(lens.parity, scope=function, role=Verifies) + // @relation(lens.porcelain, lens.parity, scope=function, role=Verifies) fn porcelain_separates_records_with_one_blank_line() { let rows = vec![ ("a".repeat(40), issue()),
crates/kernel/ents-attrs/src/lib.rs @@ -11,6 +11,7 @@ extern crate self as ents_attrs; +// @relation(model.presentation, scope=file) facet::define_attr_grammar! { ns "ents"; crate_path ::ents_attrs;
crates/cli/git-ents/src/commands/comment.rs @@ -192,6 +192,9 @@ /// (`path` alone for a whole-file anchor) and `-` when there is no anchor /// or the file is gone. Records are separated by one blank line — a blank /// body line renders as a lone tab, so it can never terminate a record. +/// This is the record grammar every other family's porcelain shares +/// (`lens.porcelain`, via [`ents_forge::present::record`]). +// @relation(lens.porcelain, scope=function) #[must_use] pub fn porcelain(rows: &[Listed]) -> String { let mut out = String::new();