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
22 changes: 22 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "process"

[dev-dependencies]
assert_cmd = "2.2.2"
csv = "1.4.0"
predicates = "3.1.4"
tempfile = "3.27.0"
wiremock = "0.6.5"
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ postil doctor
POSTIL_API_BASE=http://localhost:8000/v1 \
MODEL_API_KEY=local \
REVIEW_MODEL=<served-model-name> \
postil review --staged --output-json
postil review --staged --output json
```

Use the live benchmark harness before standardizing on a model:
Expand All @@ -203,7 +203,7 @@ would change — which findings would be suppressed and which gate outcomes woul
Deterministic, no model calls.

```sh
postil review --staged --output-json > .cache/envelopes/r1.json
postil review --staged --output json > .cache/envelopes/r1.json
postil plan --envelopes .cache/envelopes --config .postil.candidate.yaml
```

Expand All @@ -220,9 +220,12 @@ Bitbucket incremental reviews are disabled unless

## The envelope

`--output-json` prints a stable versioned envelope (`summary`, `silent`, `findings`,
`--output json` prints a stable versioned envelope (`summary`, `silent`, `findings`,
`resolved`, `counts`, `confidenceBuckets`, `gate`, `modelUsed`, `usage`, SHAs) consumed
by the hosted platform and `postil plan`. Schema: [postil.dev/docs/envelope](https://postil.dev/docs/envelope).
by the hosted platform and `postil plan`. `--output yaml` and `--output csv` print the
same review result in YAML or CSV. `--output-file <path>` writes the selected format to
a file instead of stdout. `--output-json` is deprecated in v0.2.1 as an alias for
`--output json` and emits a stderr warning. Schema: [postil.dev/docs/envelope](https://postil.dev/docs/envelope).

Exit codes: `0` clean or below gate threshold, `1` gate-failing findings, `2`
operational error.
Expand Down
17 changes: 17 additions & 0 deletions bench/fixtures/cases.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test";
import { makeDiff } from "./cases";

describe("makeDiff", () => {
test("uses one-line hunk counts for one-line fixtures", () => {
const diff = makeDiff("src/example.ts", [{ line: 10, before: "return old;", after: "return next;" }]);
expect(diff).toContain("@@ -10,1 +10,1 @@");
});

test("uses real hunk line counts for multiline fixtures", () => {
const diff = makeDiff("src/example.ts", [
{ line: 10, before: "const a = 1;\nreturn a;", after: "const a = 1;\nconst b = 2;\nreturn a + b;" },
]);
expect(diff).toContain("@@ -10,2 +10,3 @@");
expect(diff).toContain("- const a = 1;\n- return a;\n+ const a = 1;\n+ const b = 2;\n+ return a + b;");
});
});
16 changes: 10 additions & 6 deletions bench/fixtures/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,21 @@ function makeHeadSha(seed: number): string {
return seed.toString(16).padStart(2, "0").repeat(20).slice(0, 40);
}

function makeDiff(path: string, hunks: FixtureHunk[]): string {
export function makeDiff(path: string, hunks: FixtureHunk[]): string {
return [
`diff --git a/${path} b/${path}`,
"index 1111111..2222222 100644",
`--- a/${path}`,
`+++ b/${path}`,
...hunks.flatMap((hunk) => [
`@@ -${hunk.line},1 +${hunk.line},1 @@`,
`- ${hunk.before}`,
`+ ${hunk.after}`,
]),
...hunks.flatMap((hunk) => {
const beforeLines = hunk.before.split("\n");
const afterLines = hunk.after.split("\n");
return [
`@@ -${hunk.line},${beforeLines.length} +${hunk.line},${afterLines.length} @@`,
...beforeLines.map((line) => `- ${line}`),
...afterLines.map((line) => `+ ${line}`),
];
}),
"",
].join("\n");
}
Expand Down
45 changes: 45 additions & 0 deletions bench/src/livemodels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test";
import { formatLiveModelsReport, type LiveModelsReport } from "./livemodels";

describe("formatLiveModelsReport", () => {
test("formats dollar values to four decimal places", () => {
const cost = 0.123456;
const report: LiveModelsReport = {
generatedAt: "2026-07-11T00:00:00.000Z",
cliVersion: "postil 0.2.1",
apiBase: "https://example.test/v1",
models: [],
modelAggregates: [
{
id: "test/model",
provider: null,
detectionRate: 1,
falsePositives: 0,
casesRun: 1,
defectCases: 1,
cleanCases: 0,
detections: 1,
misses: 0,
gateCorrect: 1,
gateIncorrect: 0,
severityExact: 1,
severityWithinOne: 1,
totalCostUsd: cost,
meanCostUsdPerReview: cost,
pricingKnown: true,
meanDurationMs: 1200,
errors: 0,
},
],
totalRunCostUsd: cost,
cases: [],
};

const output = formatLiveModelsReport(report);
expect(output).toContain("$0.1235");
expect(output).not.toContain("$0.123456");

const displayed = Number(output.match(/Total run cost: \$(\d+\.\d{4})/)?.[1]);
expect(displayed).toBeCloseTo(cost, 4);
});
});
2 changes: 1 addition & 1 deletion bench/src/livemodels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ function pct(v: number): string {
}

function usd(v: number): string {
return `$${v.toFixed(6)}`;
return `$${v.toFixed(4)}`;
}

function pad(s: string, width: number): string {
Expand Down
10 changes: 9 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use std::path::PathBuf;

use clap::{Parser, Subcommand, ValueEnum};

use crate::output::OutputFormat;

#[derive(Parser)]
#[command(
name = "postil",
Expand Down Expand Up @@ -66,8 +68,14 @@ pub enum Command {
/// Previous review envelope for finding reconciliation.
#[arg(long)]
baseline: Option<PathBuf>,
/// Print the envelope JSON on stdout (machine consumers).
/// Print the review envelope in this format on stdout: json, yaml, or csv.
#[arg(long, value_enum)]
output: Option<OutputFormat>,
/// Write --output or --output-json data to this path instead of stdout.
#[arg(long)]
output_file: Option<PathBuf>,
/// Deprecated in v0.2.1: use --output json. Prints the envelope JSON.
#[arg(long, conflicts_with = "output")]
output_json: bool,
/// Write SARIF 2.1.0 to this path for code-scanning ingestion.
#[arg(long)]
Expand Down
4 changes: 4 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ async fn dispatch(cli: Cli) -> anyhow::Result<i32> {
gate_check_run_id,
since_sha,
baseline,
output,
output_file,
output_json,
sarif,
fail_on,
Expand Down Expand Up @@ -62,6 +64,8 @@ async fn dispatch(cli: Cli) -> anyhow::Result<i32> {
gate_check_run_id,
since_sha,
baseline,
output,
output_file,
output_json,
sarif,
fail_on,
Expand Down
120 changes: 116 additions & 4 deletions src/output.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,122 @@
//! Terminal rendering. JSON mode prints the envelope alone on stdout;
//! human chatter goes to stderr so pipelines stay clean.
//! Terminal rendering. Machine output prints the envelope alone on stdout or
//! writes it to the requested file; human chatter goes to stderr so pipelines
//! stay clean.

use std::io::IsTerminal;
use std::path::Path;

use anyhow::Context;
use clap::ValueEnum;
use owo_colors::OwoColorize;

use crate::envelope::{Envelope, Severity};

pub fn print_envelope_json(envelope: &Envelope) -> anyhow::Result<()> {
println!("{}", serde_json::to_string_pretty(envelope)?);
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
Json,
Yaml,
Csv,
}

pub fn write_envelope(
envelope: &Envelope,
format: OutputFormat,
path: Option<&Path>,
) -> anyhow::Result<()> {
let rendered = render_envelope(envelope, format)?;
if let Some(path) = path {
std::fs::write(path, rendered)
.with_context(|| format!("writing {} output to {}", format.as_str(), path.display()))?;
} else {
print!("{rendered}");
}
Ok(())
}

impl OutputFormat {
pub fn as_str(self) -> &'static str {
match self {
OutputFormat::Json => "json",
OutputFormat::Yaml => "yaml",
OutputFormat::Csv => "csv",
}
}
}

fn render_envelope(envelope: &Envelope, format: OutputFormat) -> anyhow::Result<String> {
Ok(match format {
OutputFormat::Json => format!("{}\n", serde_json::to_string_pretty(envelope)?),
OutputFormat::Yaml => serde_yaml::to_string(envelope)?,
OutputFormat::Csv => render_csv(envelope),
})
}

fn render_csv(envelope: &Envelope) -> String {
let mut out = String::from(
"version,silent,summary,path,line,endLine,severity,kind,confidence,title,body,\
gateFailOn,gateFailing,modelUsed,promptTokens,completionTokens,durationMs,baseSha,\
headSha,sinceSha\n",
);
if envelope.findings.is_empty() {
push_csv_row(&mut out, envelope, None);
} else {
for finding in &envelope.findings {
push_csv_row(&mut out, envelope, Some(finding));
}
}
out
}

fn push_csv_row(out: &mut String, envelope: &Envelope, finding: Option<&crate::envelope::Finding>) {
let mut fields = vec![
envelope.version.to_string(),
envelope.silent.to_string(),
envelope.summary.clone(),
];
if let Some(finding) = finding {
fields.extend([
finding.path.clone(),
finding.line.to_string(),
finding.end_line.map(|l| l.to_string()).unwrap_or_default(),
finding.severity.as_str().to_string(),
finding.kind.as_str().to_string(),
finding.confidence.to_string(),
finding.title.clone(),
finding.body.clone(),
]);
} else {
fields.extend(std::iter::repeat_n(String::new(), 8));
}
fields.extend([
envelope.gate.fail_on.clone(),
envelope.gate.failing.to_string(),
envelope.model_used.clone(),
envelope.usage.prompt_tokens.to_string(),
envelope.usage.completion_tokens.to_string(),
envelope.duration_ms.to_string(),
envelope.base_sha.clone().unwrap_or_default(),
envelope.head_sha.clone().unwrap_or_default(),
envelope.since_sha.clone().unwrap_or_default(),
]);
out.push_str(
&fields
.into_iter()
.map(csv_field)
.collect::<Vec<_>>()
.join(","),
);
out.push('\n');
}

fn csv_field(field: String) -> String {
let field = sanitize(&field);
if field.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field
}
}

pub fn print_pretty(envelope: &Envelope) {
let color = std::io::stderr().is_terminal();
let mut out = String::new();
Expand Down Expand Up @@ -181,4 +286,11 @@ mod tests {
assert!(out.contains("hijacked title"));
assert!(out.contains("FAKE ALL CLEAR"));
}

#[test]
fn csv_field_strips_terminal_controls_before_quoting() {
let field = csv_field("\x1b[31mred\x1b[0m,\"quoted\"\nnext".into());
assert_eq!(field, "\"[31mred[0m,\"\"quoted\"\"\nnext\"");
assert!(!field.contains('\x1b'));
}
}
Loading
Loading