Skip to content
Merged
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
15 changes: 10 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,19 @@ acquire diff --> parse supported lockfiles --> parse + index --> bounded evidenc
resources, values, versions, paths, and identifiers. One receipt binds those queries to
the immutable reviewed head and reports `complete`, `unavailable`, or `exhausted`.
Only a complete receipt with no positive counterexample supports a universal claim.
Incomplete repository evidence cannot confirm it, so the original finding remains open.
Incomplete repository evidence cannot confirm a fresh claim, so the claim is suppressed;
a prior-ledger claim remains open until exact-head adjudication resolves it.
Every surviving generated candidate, plus applicable baseline candidates during a full
rereview, enters one bounded adjudication operation before scoring and publication. The
operation admits the complete candidate set or fails closed before provider contact. Its
direct-source receipt hashes and scans the complete diff, records deterministic citation
occurrence counts, and carries only bounded evidence windows to the model. Adjudication
validates exact candidate identities, result completeness, evidence, publication text,
and duplicate primaries. Later and cross-file evidence can refute stale claims, while
unresolved claims remain open. Semantic duplicates collapse across files and kinds
only when one established defect remains; distinct defects sharing a line remain separate.
and duplicate primaries. Later and cross-file evidence can refute stale claims. Fresh
unresolved repository claims are suppressed, ordinary grounded unresolved findings and
prior-ledger claims remain open, and provider or contract failure preserves every candidate.
Semantic duplicates collapse across files and kinds only when one established defect remains;
distinct defects sharing a line remain separate.
- `forge/`: trait + GitHub, GitLab, Bitbucket Cloud, and Azure DevOps implementations,
with self-managed base URLs where the same API contract applies. Paginated forge
metadata has aggregate byte and changed-file bounds. Source responses stream to
Expand Down Expand Up @@ -266,7 +269,9 @@ additions (violations are `kind: contentPolicy`). Content policy is on by defaul
requires the named target and compared value in one searched evidence unit. Query, request,
object, tree-depth, byte, deadline, and detailed-match bounds are explicit in the receipt
outcome. Public findings state the repository construct and correction without describing
evidence retrieval boundaries or delegating evidence collection to the author.
evidence retrieval boundaries or delegating evidence collection to the author. Fresh
unresolved repository claims are suppressed; prior-ledger claims remain open until an
exact-head adjudication explicitly resolves them.
15. Finding adjudication performs exactly one logical provider operation over every admitted
candidate. Candidate identities bind the exact snapshot and semantic finding fields.
Adjudication input, output, attempts, deadline, and projected cost are bounded, and results
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "postil-cli"
version = "0.8.16"
version = "0.8.17"
edition = "2024"
description = "Postil: a low-noise AI review gate. Silent on clean PRs, hard gate on real risk."
license = "Apache-2.0"
Expand Down
7 changes: 7 additions & 0 deletions src/adjudication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ pub(crate) struct AdjudicationCandidate<'a> {
pub(crate) struct AdjudicationApplication {
pub kept: Vec<Finding>,
pub kept_indices: Vec<usize>,
pub unresolved_indices: Vec<usize>,
pub resolved_indices: Vec<usize>,
pub suppressed: Vec<SuppressedFinding>,
}
Expand Down Expand Up @@ -1288,6 +1289,7 @@ pub(crate) fn apply_results(
.collect::<HashMap<_, _>>();
let mut kept = Vec::new();
let mut kept_indices = Vec::new();
let mut unresolved_indices = Vec::new();
let mut resolved_indices = Vec::new();
let mut suppressed = Vec::new();
for (index, (mut finding, id)) in findings.into_iter().zip(candidate_ids).enumerate() {
Expand All @@ -1308,13 +1310,15 @@ pub(crate) fn apply_results(
}
(AdjudicationProvenance::Model, AdjudicationDisposition::PreserveUnresolved) => {
kept_indices.push(index);
unresolved_indices.push(index);
kept.push(finding);
}
(
AdjudicationProvenance::DeterministicEvidenceReceipt(_),
AdjudicationDisposition::PreserveUnresolved,
) => {
kept_indices.push(index);
unresolved_indices.push(index);
kept.push(finding);
}
(AdjudicationProvenance::Model, AdjudicationDisposition::SuppressRefuted) => {
Expand All @@ -1337,6 +1341,7 @@ pub(crate) fn apply_results(
Ok(AdjudicationApplication {
kept,
kept_indices,
unresolved_indices,
resolved_indices,
suppressed,
})
Expand Down Expand Up @@ -2658,6 +2663,7 @@ mod tests {
assert_eq!(kept.evidence, original.evidence);
}
assert_eq!(applied.kept_indices, vec![0, 1]);
assert_eq!(applied.unresolved_indices, vec![0, 1]);
assert!(applied.resolved_indices.is_empty());
assert!(applied.suppressed.is_empty());
}
Expand Down Expand Up @@ -2725,6 +2731,7 @@ mod tests {
assert_eq!(applied.kept[1].body, unresolved.body);
assert_eq!(applied.kept[1].evidence, unresolved.evidence);
assert_eq!(applied.kept_indices, vec![0, 2]);
assert_eq!(applied.unresolved_indices, vec![2]);
assert_eq!(applied.resolved_indices, vec![1]);
assert_eq!(
applied.suppressed[0].reason,
Expand Down
249 changes: 248 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ pub enum ForgeArg {
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)] // Review carries the full flag set by design.
pub enum Command {
/// Probe machine-readable CLI capabilities without external access.
Capabilities {
/// Require and print an exact publication-plan contract identifier.
#[arg(long, value_name = "IDENTIFIER")]
publication_plan_contract: String,
},
/// Print immutable qualification metadata embedded in this binary.
#[command(hide = true)]
QualificationMetadata,
Expand Down Expand Up @@ -141,6 +147,44 @@ pub enum Command {
/// Leave the merge-gate check pending for a controlling service to complete.
#[arg(long, requires = "publish")]
defer_gate_check: bool,
/// Write an immutable GitHub publication plan without mutating the forge.
#[arg(
long,
hide = true,
value_name = "PATH",
requires_all = [
"repo",
"pr",
"sha",
"base_sha",
"publication_generation",
"publication_input_identity"
],
conflicts_with_all = [
"publish",
"no_post",
"check_run_id",
"gate_check_run_id",
"defer_gate_check"
]
)]
publication_plan_output: Option<PathBuf>,
/// Service-owned generation identity for publication planning.
#[arg(
long,
hide = true,
value_name = "IDENTITY",
requires = "publication_plan_output"
)]
publication_generation: Option<String>,
/// Service-supplied immutable input identity for publication planning.
#[arg(
long,
hide = true,
value_name = "SHA256",
requires = "publication_plan_output"
)]
publication_input_identity: Option<String>,
},
/// Reply to an @postil mention on a pull request or issue (interactive bot).
Respond {
Expand Down Expand Up @@ -214,6 +258,15 @@ pub enum Command {
Logout,
}

pub fn publication_plan_contract_capability(required: &str) -> anyhow::Result<&'static str> {
anyhow::ensure!(
required == crate::forge::GITHUB_PUBLICATION_PLAN_CONTRACT,
"unsupported publication-plan contract {required:?}; supported contract: {}",
crate::forge::GITHUB_PUBLICATION_PLAN_CONTRACT
);
Ok(crate::forge::GITHUB_PUBLICATION_PLAN_CONTRACT)
}

#[derive(Subcommand)]
pub enum HookAction {
/// Install a pre-push hook that reviews outgoing commits.
Expand All @@ -227,7 +280,33 @@ pub enum HookAction {
mod tests {
use clap::{CommandFactory, Parser};

use super::{Cli, Command};
use super::{Cli, Command, publication_plan_contract_capability};

const PUBLICATION_INPUT_IDENTITY: &str =
"sha256:1111111111111111111111111111111111111111111111111111111111111111";

#[test]
fn publication_plan_capability_requires_the_exact_contract() {
let parsed = Cli::try_parse_from([
"postil",
"capabilities",
"--publication-plan-contract",
"github-publication-v1",
])
.unwrap();
let Command::Capabilities {
publication_plan_contract,
} = parsed.command
else {
panic!("expected capabilities command");
};
assert_eq!(publication_plan_contract, "github-publication-v1");
assert_eq!(
publication_plan_contract_capability(&publication_plan_contract).unwrap(),
"github-publication-v1"
);
assert!(publication_plan_contract_capability("github-publication-v2").is_err());
}

#[test]
fn review_bounded_is_an_explicit_action_flag_with_concise_help() {
Expand Down Expand Up @@ -356,6 +435,174 @@ mod tests {
);
}

#[test]
fn publication_plan_is_hidden_snapshot_bound_and_separate_from_mutation() {
let parsed = Cli::try_parse_from([
"postil",
"review",
"--repo",
"postil-dev/postil",
"--pr",
"1",
"--sha",
"aaaaaaaa",
"--base-sha",
"bbbbbbbb",
"--publication-plan-output",
"publication-plan.json",
"--publication-generation",
"1",
"--publication-input-identity",
PUBLICATION_INPUT_IDENTITY,
])
.unwrap();
let Command::Review {
publication_plan_output,
publication_generation,
publication_input_identity,
publish,
..
} = parsed.command
else {
panic!("expected review command");
};
assert_eq!(
publication_plan_output.as_deref(),
Some(std::path::Path::new("publication-plan.json"))
);
assert_eq!(publication_generation.as_deref(), Some("1"));
assert_eq!(
publication_input_identity.as_deref(),
Some(PUBLICATION_INPUT_IDENTITY)
);
assert!(!publish);

let help = Cli::command()
.find_subcommand_mut("review")
.expect("review subcommand")
.render_long_help()
.to_string();
assert!(!help.contains("publication-plan-output"));

for extra in [
vec!["--publish"],
vec!["--no-post"],
vec!["--check-run-id", "901"],
vec!["--gate-check-run-id", "902"],
] {
let mut arguments = vec![
"postil",
"review",
"--repo",
"postil-dev/postil",
"--pr",
"1",
"--sha",
"aaaaaaaa",
"--base-sha",
"bbbbbbbb",
"--publication-plan-output",
"publication-plan.json",
"--publication-generation",
"1",
"--publication-input-identity",
PUBLICATION_INPUT_IDENTITY,
];
arguments.extend(extra);
assert!(Cli::try_parse_from(arguments).is_err());
}

assert!(
Cli::try_parse_from([
"postil",
"review",
"--repo",
"postil-dev/postil",
"--pr",
"1",
"--sha",
"aaaaaaaa",
"--publication-plan-output",
"publication-plan.json",
"--publication-generation",
"1",
"--publication-input-identity",
PUBLICATION_INPUT_IDENTITY,
])
.is_err()
);
assert!(
Cli::try_parse_from([
"postil",
"review",
"--repo",
"postil-dev/postil",
"--pr",
"1",
"--sha",
"aaaaaaaa",
"--base-sha",
"bbbbbbbb",
"--publication-plan-output",
"publication-plan.json",
"--publication-generation",
"1",
])
.is_err()
);
assert!(
Cli::try_parse_from([
"postil",
"review",
"--repo",
"postil-dev/postil",
"--pr",
"1",
"--sha",
"aaaaaaaa",
"--base-sha",
"bbbbbbbb",
"--publication-plan-output",
"publication-plan.json",
"--publication-input-identity",
PUBLICATION_INPUT_IDENTITY,
])
.is_err()
);
assert!(
Cli::try_parse_from([
"postil",
"review",
"--publication-input-identity",
PUBLICATION_INPUT_IDENTITY,
])
.is_err()
);
assert!(
Cli::try_parse_from([
"postil",
"review",
"--repo",
"postil-dev/postil",
"--pr",
"1",
"--sha",
"aaaaaaaa",
"--base-sha",
"bbbbbbbb",
"--publication-plan-output",
"publication-plan.json",
"--publication-generation",
"1",
"--publication-input-identity",
PUBLICATION_INPUT_IDENTITY,
"--publication-input-identity",
PUBLICATION_INPUT_IDENTITY,
])
.is_err()
);
}

#[test]
fn login_accepts_an_optional_org_hint() {
let parsed = Cli::try_parse_from(["postil", "login"]).unwrap();
Expand Down
Loading