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
6 changes: 6 additions & 0 deletions .github/homebrew-formula.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ class Jevgate < Formula

def install
bin.install "jevgate"
generate_completions_from_executable(bin/"jevgate", "completions")
man1.mkpath
(man1/"jevgate.1").write Utils.safe_popen_read(bin/"jevgate", "man")
%w[auth check baseline rules init serve completions man].each do |command|
(man1/"jevgate-#{command}.1").write Utils.safe_popen_read(bin/"jevgate", "man", command)
end
end

test do
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver

## [Unreleased]

- `jevgate completions SHELL` prints a completion script for bash, zsh, fish, elvish or PowerShell, and `jevgate man [COMMAND]` a man page, both generated from the same definitions as `--help`. The Homebrew formula installs them.
- Homebrew: `brew install tech-byte-frontier/tap/jevgate` installs the release binaries on macOS and Linux, and each release updates the formula.

## [0.17.0] - 2026-09-25
Expand Down
27 changes: 27 additions & 0 deletions Cargo.lock

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

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ pkg-fmt = "zip"

[dependencies]
anyhow = "=1.0.104"
clap = { version = "=4.6.7", features = ["derive"] }
clap = { version = "=4.6.7", features = ["derive", "string"] }
clap_complete = "=4.6.11"
clap_mangen = "=0.3.3"
globset = "=0.4.20"
ignore = "=0.4.33"
keyring = { version = "=4.2.0", default-features = false, features = ["v1"] }
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ cargo install jevgate --locked # build from source; needs Rust 1.90 or later

Each [release](https://github.com/Tech-Byte-Frontier/jevgate/releases) has binaries for Linux (x86_64 and arm64, static), macOS (Apple silicon and Intel) and Windows (x86_64), with SHA-256 checksums and build provenance: `gh attestation verify <archive> --repo Tech-Byte-Frontier/jevgate`. The install script checks the checksum and installs to `~/.local/bin`; set `JEVGATE_VERSION` or `JEVGATE_INSTALL_DIR` to change the version or place.

`jevgate completions bash|zsh|fish|powershell` prints a shell completion script and `jevgate man` a man page; Homebrew installs both.

Reviewing needs a [TypeSafe API key](https://console.typesafe.ai/settings/keys). Git is needed only for `--base` and the staleness rule.

## Quick start
Expand Down
111 changes: 111 additions & 0 deletions src/check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//! `check`: collect the selected files, evaluate them, apply the gate, and
//! report once or keep watching.
use crate::{
cancellation, changes,
config::ConfigContext,
evaluate, gate, html_report, inventory,
options::{CheckArgs, Format},
output, schema, storage, token_budget, transport, watch,
};
use anyhow::Result;

fn validate(args: &CheckArgs) -> Result<()> {
anyhow::ensure!(
!args.show_requests || args.output_format() == Format::Json,
"--show-requests uses JSON output; omit --format or use --format json"
);
anyhow::ensure!(
!(args.watch && args.dry_run),
"--watch cannot be combined with --dry-run"
);
anyhow::ensure!(
!(args.watch && matches!(args.output_format(), Format::Json | Format::Github)),
"Use --format jsonl for watch snapshots"
);
Ok(())
}

/// The credential file: `--env-file` from the invocation directory, else the root `.env`.
fn credential_path(args: &CheckArgs, context: &ConfigContext) -> std::path::PathBuf {
args.env_file
.as_ref()
.map(|p| context.input_path(p))
.unwrap_or_else(|| context.root.join(".env"))
}

/// Record a failed evaluation in the snapshot (and report) before returning the error.
fn publish_failure(
session: &evaluate::Session<'_>,
report: &mut schema::Report,
error: anyhow::Error,
) -> Result<u8> {
report.watcher_pid = None;
report.errors.push(error.to_string());
report.update_status();
session.publish(report)?;
if session.args.report {
html_report::open(&session.context.root);
}
Err(error)
}

/// `check`: judge the selected files, apply the gate and report.
pub fn run(args: &CheckArgs, context: &ConfigContext) -> Result<u8> {
validate(args)?;
cancellation::install()?;
let scope = inventory::scope(args, context)?;
let inputs = inventory::collect(args, context, &scope)?;
let store = if args.dry_run {
None
} else {
Some(storage::Store::open(&context.root)?)
};
let baseline = storage::read_latest(&context.root).ok();
let previous = evaluate::previous_judgments(baseline.as_ref(), args.refresh);
let mut report = evaluate::snapshot(
&inputs,
&previous,
args,
evaluate::SnapshotContext {
root: &context.root,
generation: baseline.as_ref().map_or(1, |r| r.generation + 1),
requests: 0,
},
);
if args.dry_run {
output::emit(&report, args)?;
return Ok(0);
}
let store = store.unwrap();
let mut client =
transport::Client::new(&credential_path(args, context), args.env_file.is_some());
let mut session = evaluate::Session {
args,
context,
store: &store,
evaluator: &mut client,
requests: 0,
paid_input_tokens: 0,
paid_output_tokens: 0,
budget: token_budget::TokenBudget::load(&context.root),
observed: (0, 0),
};
if let Err(error) = session.evaluate(&inputs, &mut report) {
return publish_failure(&session, &mut report, error);
}
changes::compare(baseline.as_ref(), &mut report);
gate::settle(&context.root, &mut report, args)?;
report.settled = true;
session.publish(&report)?;
if args.report {
html_report::open(&context.root);
}
if args.output_format() != Format::Jsonl {
output::emit(&report, args)?;
}
if args.watch {
watch::run(&mut session, scope, inputs, report)?;
return Ok(0);
}
Ok(gate::exit_code(&report))
}
133 changes: 133 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//! Running each command: offline commands first, then the ones that read
//! the repository's configuration; `check` runs in its own module.
use crate::{
auth, baseline, cancellation, catalog, config,
config::ConfigContext,
init, manual,
options::{self, JevCommand},
output, revision, server,
};
use anyhow::Result;

pub fn run(command: JevCommand) -> Result<u8> {
match command {
JevCommand::Auth { command } => auth::run(command),
JevCommand::Completions { shell } => manual::completions(shell).map(|()| 0),
JevCommand::Man { command } => manual::man(command.as_deref()).map(|()| 0),
JevCommand::Init { force } => init(force),
command => configured(command),
}
}

/// `init` runs before configuration is read, so an invalid file can be replaced.
fn init(force: bool) -> Result<u8> {
let root = config::repository_root(&std::env::current_dir()?.canonicalize()?);
let (path, allow) = init::run(&root, force)?;
say!("Wrote {}", path.display());
if allow.is_empty() {
say!("No supported source found; set upload_allow before checking.");
} else {
say!("Uploads limited to: {}", allow.join(", "));
}
say!("Next: jevgate auth login, then jevgate check --dry-run --show-requests");
Ok(0)
}

/// The commands that read the repository's configuration.
fn configured(command: JevCommand) -> Result<u8> {
let file = match &command {
JevCommand::Check(args) => args.config.clone(),
_ => None,
};
let context = ConfigContext::discover(file.as_deref())?;
match command {
JevCommand::Auth { .. }
| JevCommand::Init { .. }
| JevCommand::Completions { .. }
| JevCommand::Man { .. } => {
unreachable!("handled before repository configuration")
}
JevCommand::Check(mut args) => {
context.configure(&mut args)?;
if let Some(base) = &args.base {
args.base = Some(revision::resolve(&context.root, base)?);
}
crate::check::run(&args, &context)
}
JevCommand::Baseline {
action: Some(action),
..
} => baseline_action(&context, action),
JevCommand::Baseline {
merge,
reason,
action: None,
} => accept(&context, merge, reason),
JevCommand::Rules { format } => {
match format {
options::RulesFormat::Json => {
say!("{}", serde_json::to_string_pretty(&catalog::describe())?)
}
options::RulesFormat::Table => say!("{}", catalog::table()),
}
Ok(0)
}
JevCommand::Serve { port } => {
cancellation::install()?;
server::run(&context.root, port)?;
Ok(0)
}
}
}

/// `baseline`: accept the last check's findings.
fn accept(
context: &ConfigContext,
merge: bool,
reason: Option<options::Disposition>,
) -> Result<u8> {
let written = baseline::write(&context.root, merge, reason)?;
let path = written.path.display();
if merge {
say!(
"Accepted {} finding(s) from the last check in {path}; kept {} earlier finding(s) for files it did not cover",
written.accepted,
written.kept
);
} else {
say!("Accepted {} finding(s) in {path}", written.accepted);
}
Ok(0)
}

/// `baseline mark` and `baseline stats`: offline edits and counts of the baseline.
fn baseline_action(context: &ConfigContext, action: options::BaselineAction) -> Result<u8> {
match action {
options::BaselineAction::Mark {
reason,
targets,
rules,
} => {
let mut keys = Vec::new();
for name in &rules {
keys.extend(
catalog::select(name)
.ok_or_else(|| anyhow::anyhow!("Unknown rule or group: {name}"))?,
);
}
let marked = baseline::mark(&context.root, reason, &targets, &keys)?;
say!(
"Marked {marked} accepted finding(s) as {}",
output::label(&reason)
);
}
options::BaselineAction::Stats { format } => {
let counts = baseline::stats(&context.root)?;
match format {
options::RulesFormat::Json => say!("{}", serde_json::to_string_pretty(&counts)?),
options::RulesFormat::Table => say!("{}", baseline::stats_table(&counts)),
}
}
}
Ok(0)
}
Loading
Loading