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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver

## [Unreleased]

A whole-repository check now finishes where it stopped halfway, says what it is doing while it runs, and fails at once, in one line, without a key. Measured on a 950-file TypeScript and Python project whose jevgate.toml, written for 0.8, set `maintainability = "consider"`, `max_requests = 1000` and `concurrency = 4`: 0.31.0 planned 1,126 first-pass requests, spent its 1,000 on them in 70 silent seconds and left 377 files unchecked, advising a larger `--max-requests`, which could not raise the ceiling jevgate.toml set; this version asks 930 requests in 73 seconds from an empty cache, drawing a status line all along, and finishes ($0.05). A rerun from the cache takes 2 seconds.

### Checks of the whole repository

- A group's level no longer turns on its opt-in rules. `maintainability = "consider"` in `[rules]`, `rules = ["maintainability"]` and `--rule maintainability` turn on file organization, function simplification and shared logic, not hardcoded values, which left the default rules in 0.26 for being right 6 times in 37 on projects JevGate was never tuned on; it runs when named (`"maintainability/hardcoded-values" = "consider"`, `--rule hardcoded-values`) or with `all`. A group with no rule on by default, such as `security` or `documentation`, still turns on every rule of it, and skipping a group still skips all of it. On the project above, the group's level asked 1,846 requests for a whole check where its three default rules need 943: hardcoded values asks about every module constant and every function with a literal, and rechecks and locates many of them. `jevgate init` writes hardcoded values on a line of its own, and a jevgate.toml written by `init` before 0.26 is no longer said to judge it.
- A check says what it is doing on a terminal: one line on stderr, drawn again every eighth of a second, with the stage (reading files, planning, first pass, rechecking undecided units, locating findings…), the requests of that stage answered so far and the time, such as `JevGate · first pass · 312/768 answered · 23s`. Every other line JevGate prints erases it first, and it is gone before the findings. It is not drawn in CI, with `--watch` or `--format jsonl`, when stderr is not a terminal, or for a check that ends within 0.4 seconds.
- A check whose request budget cannot cover it says so on stderr before its first request, with the number of requests it needs at least, and each request left unsent names the budget and where it is set: `Request budget reached (max_requests = 1000 in jevgate.toml); rerun to continue from the cached answers, or raise the budget`. The old message advised a larger `--max-requests`, which can only lower the ceiling jevgate.toml sets.
- Without a key, a check ends at once with one line, `jevgate: No API key configured. Run jevgate auth login, …`, and exit 2, where it reported 660 of the project's 950 files failed, 38 of them for a request budget no request had been sent against. The report still records each file's error, a hook still lets the change through, saying why, and a watcher keeps looking for a key.
- On macOS, the key `jevgate auth login` saved in the Keychain is read without a terminal: Git hooks and coding agents' hooks, whose stdin is not a terminal, did not find it and let every change through unchecked. Without a terminal the Keychain's dialog is turned off, so a read macOS would ask about (the first by a newly upgraded binary) fails at once, saying to run `jevgate auth status` in a terminal once and choose Always Allow, instead of waiting on a dialog nobody may see.
- The agent text marks a finding of a run whose gate was not evaluated, such as one left incomplete, `(would fail the gate)`, not `(fails the gate)`.
- The configuration example and `jevgate init` suggest `max_cost` to bound a check's spend, rather than a `max_requests` sized for pull requests, which stops a check of the whole repository.

## [0.31.0] - 2026-09-28

0.31.0 brings the gate to the roadmap's third moment, the commit: Git hooks judge what a push sends or a commit records, read from Git, and a check that cannot finish lets the change through and says so. Releases now stage the npm package for the maintainer's approval.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ schemars = "=1.2.2"
libc = "=0.2.189"
signal-hook = { version = "=0.4.4", default-features = false }

# Keychain reads without a terminal turn macOS's dialog off (`auth::store`).
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = { version = "=3.7.0", default-features = false }

[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))'.dependencies]
secret-service = { version = "=5.2.0", features = ["rt-async-io-crypto-rust"] }
zbus = "=5.19.0"
Expand Down
10 changes: 5 additions & 5 deletions site/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
upload_allow = ["src/**", "tests/**"] # only these paths may be uploaded
upload_deny = ["**/.env*", "**/*.pem", "**/*.key"]
include_tests = true
max_requests = 300
max_cost = 1.00 # dollars a check may spend; a whole-repository check costs a few cents

[rules] # a level per group or rule
maintainability = "review" # judge every rule of the group, and fail on its reviews
maintainability = "review" # judge the group's default rules, and fail on their reviews
tests = "consider"
security = "mature" # opt-in group, enabled by naming it; fails only on levels measured mature
"maintainability/hardcoded-values" = "report" # judge but never fail; "off" skips it
"maintainability/hardcoded-values" = "report" # an opt-in rule runs only when named: judge but never fail; "off" skips it

[[scope]] # levels for the files these paths match
paths = ["scripts/**", "tools/**"]
Expand All @@ -27,13 +27,13 @@ rules = { security = "consider" } # except these
| `generated` | built-in names | Globs of generated files, which are skipped |
| `tests` | built-in conventions | Globs of additional test files |
| `context` | none | Files always sent as related evidence, like `--context` |
| `rules` | the `default` group | A list selects rules. A table gives each group or rule a level: `review`, `consider`, `mature`, `uncertain`, `report` (judge, never fail) or `off`; a level for a group judges every rule of it, opt-in ones included |
| `rules` | the `default` group | A list selects rules. A table gives each group or rule a level: `review`, `consider`, `mature`, `uncertain`, `report` (judge, never fail) or `off`; a level for a group judges the rules it runs by default (`maintainability` leaves out the opt-in hardcoded values, which runs only when named), and every rule of a group with none on by default, such as `security` |
| `[[scope]]` | none | `paths` (globs), with `fail_on` for every rule and `rules` for rules or groups, as above; `off` is not accepted (use `upload_deny`). The last scope that matches a file and addresses a rule wins; flags win over scopes |
| `fail_on` | `["mature"]` | The level for rules without their own, like `--fail-on` |
| `include_tests` | `false` | Judge tests, like `--include-tests` |
| `model` | the key's provider's | The model, as the key's provider names it: `jev-1.13.0` for TypeSafe, `typesafe/jev-1.13` for OpenRouter, `typesafe-ai/jev` for Vercel AI Gateway. A pinned version keeps results repeatable; a repository that sets it for one provider needs `--model` with another provider's key |
| `cache_ttl_secs` | `3600` | Cache lifetime for an alias: a model name without an `x.y.z` version, such as `jev-latest` or `jev-1.13`. Pinned versions such as `jev-1.13.0` never expire |
| `max_requests` | unlimited | Ceiling on API attempts per invocation |
| `max_requests` | unlimited | Ceiling on API attempts per invocation; `--max-requests` can only lower it. A whole-repository check asks about one request per file, and more with opt-in rules, so a ceiling sized for pull requests stops it: a check that will not fit says so before its first request. Prefer `max_cost` to bound spend |
| `max_seconds` | `60` with `--staged` and `--pre-push`, else unlimited | Ceiling on the seconds a check asks for: no request starts, and no retry waits, past it, and what is left unasked leaves the run incomplete; `--max-seconds` can only lower it |
| `max_cost` | unlimited | Ceiling on a check's estimated spend in dollars: each request is priced from its size before it is sent, stderr says when 75% and 90% are spent, and what would pass it is left unasked, leaving the run incomplete; `--max-cost` can only lower it |
| `on_incomplete` | `"pass"` with `--staged` and `--pre-push`, else `"fail"` | What a run that cannot finish exits with, like `--on-incomplete`: `"fail"` exits 2; `"pass"` exits 0 and says on stderr that the change was not checked, and why ([Git hooks](git-hooks.md#when-the-check-cannot-finish)) |
Expand Down
2 changes: 1 addition & 1 deletion site/src/git-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jevgate: it goes ahead unchecked, as on_incomplete is "pass"; set on_incomplete

## Recipes

The hooks need JevGate 0.31.0 or later on the PATH (or built by pre-commit), and a key: `jevgate auth login` saves one, or set `TYPESAFE_API_KEY`, `OPENROUTER_API_KEY` or `AI_GATEWAY_API_KEY`.
The hooks need JevGate 0.31.0 or later on the PATH (or built by pre-commit), and a key: `jevgate auth login` saves one, or set `TYPESAFE_API_KEY`, `OPENROUTER_API_KEY` or `AI_GATEWAY_API_KEY`. On macOS a hook reads the saved key from the Keychain since 0.32.0 (0.31.0 needed a terminal, which a hook lacks). After an upgrade macOS asks once whether the new `jevgate` may read it: a hook cannot answer, so run `jevgate auth status` in a terminal and choose Always Allow.

### pre-commit and prek

Expand Down
4 changes: 3 additions & 1 deletion site/src/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ Findings are `review` (act on it), `consider` (worth a look) or `note` (optional

`jevgate hook` is the exception: it exits 0 whatever happens, because agents read exit 2 as "block", and its JSON reply says what happened ([Coding agents](coding-agents.md)).

While a check runs on a terminal, one line on stderr says what it is doing, how many of that stage's requests are answered and for how long it has run: `JevGate · first pass · 312/768 answered · 23s`. It is erased before anything else is printed, and it is not drawn in CI, with `--watch` or `--format jsonl`, or when stderr is not a terminal.

After the findings, the agent text gives each reason files failed or were skipped, with how many files give it, such as `Failed 3: TypeSafe HTTP 402 (credits exhausted; …)`, so an incomplete run says why without `--verbose`, and so does the MCP server's `jevgate_check`, which returns this text.

`--fail-on review|consider|mature|uncertain|none` sets what fails the gate; `--fail-on security=consider` sets it for one group or rule. The default, `mature`, fails only on the rules and levels measured right at least 80% of the time on projects JevGate was never tuned on, never on a finding in a [preview language](languages.md#support-levels), and on each [custom question](custom-questions.md)'s own level; `jevgate rules` lists them, and [configuration](configuration.md#what-fails-the-check-by-default) explains it. Baselined findings, findings allowed by a comment, and notes never fail the gate.

The agent text marks each finding that fails the gate with `(fails the gate)`, and says when reviews did not fail it because their rules are still being measured or their files' languages are in preview. The JSON report records how the gate counted each new finding in its `gate` field: `fails`, `measuring` (reported without failing: the level is `mature` and its rule and level are still being measured, or its file's language is in preview) or `advisory` (the level in force does not count it, as `review` does not count a consider). `fail_on_mature` says what `mature` stands for among the selected rules.
The agent text marks each finding that fails the gate with `(fails the gate)`, or `(would fail the gate)` in a run whose gate was not evaluated, such as one left incomplete, and says when reviews did not fail it because their rules are still being measured or their files' languages are in preview. The JSON report records how the gate counted each new finding in its `gate` field: `fails`, `measuring` (reported without failing: the level is `mature` and its rule and level are still being measured, or its file's language is in preview) or `advisory` (the level in force does not count it, as `review` does not count a consider). `fail_on_mature` says what `mature` stands for among the selected rules.

A single finding can also be accepted where it is, with a comment on its line or directly above it (doc comments and attributes may sit in between). The comment names a rule ID (`security/injection`), its name (`injection`), its key or a group (a [custom question](custom-questions.md) by its ID, `custom/no-body-logs`, or `custom`), and needs a reason; without one it is ignored and the finding says so:

Expand Down
4 changes: 2 additions & 2 deletions site/src/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ A provider error ends with the provider's request id when it sent one (`; reques
**`jevgate: this commit was not checked: …` or `this push was not checked: …`**
: A [Git hook](git-hooks.md)'s check could not finish, for the reason given, and let the change through, as `on_incomplete = "pass"` does by default for `--staged` and `--pre-push`. Fix the cause (a key, credits, a configuration that loads) and check again with `jevgate check --base <the commit before>`, or set `on_incomplete = "fail"` for a hook that stops the change instead. `it used its 60 seconds (max_seconds)` means the provider did not answer in time: the answers received are cached, so the next run asks only for the rest; raise `max_seconds` for larger pushes.

**`Session API request budget exhausted; restart with an explicit larger --max-requests`**
: `max_requests` or `--max-requests` capped the run. Raise it, or check fewer files with `--base` or paths; `--dry-run` estimates what a run will ask.
**`Request budget reached (max_requests = N in jevgate.toml); rerun to continue from the cached answers, or raise the budget`**
: `max_requests` in jevgate.toml, or `--max-requests`, capped the run; the message names which. A flag can only lower the ceiling jevgate.toml sets, so raise `max_requests` there. The answers the run got are cached, so a rerun asks only for the rest. A check that cannot fit its budget says so on stderr before its first request, with the number it needs at least. A whole-repository check asks about one request per file, and more with opt-in rules: `--dry-run` counts the first pass, and `max_cost` bounds spend without stopping large checks.

**`Another JevGate session owns latest.json`**
: Another `check` or `--watch` is running in the same repository. Stop it first.
Expand Down
50 changes: 46 additions & 4 deletions src/auth/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,54 @@ pub trait Backend {
}

pub struct NativeBackend;

/// Whether a person answers at this terminal: stdin and stderr are both one.
fn interactive() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}

impl NativeBackend {
fn entry(&self) -> Result<keyring::Entry> {
// Credential providers may open system dialogs. Only Windows' Credential
// Manager is used through this interface without an interactive terminal.
ensure!(
cfg!(windows) || (std::io::stdin().is_terminal() && std::io::stderr().is_terminal()),
cfg!(windows) || interactive(),
"System credential operation requires a terminal; use --storage file or TYPESAFE_API_KEY for automation"
);
Self::unchecked_entry()
}

fn unchecked_entry() -> Result<keyring::Entry> {
keyring::Entry::new("jevgate", "typesafe-api-key")
.map_err(|_| anyhow::anyhow!("System credential store is unavailable or locked"))
}

/// The saved key from the macOS Keychain. Git hooks and coding agents'
/// hooks run without a terminal (Git passes a pre-push hook its refs on
/// stdin, and an agent its event), and a read that required one never
/// found the key `jevgate auth login` saved. Without a terminal the
/// Keychain's own dialog is turned off, so a read macOS would ask about,
/// such as the first by a newly upgraded binary, fails at once instead
/// of waiting on a dialog nobody may be watching.
#[cfg(target_os = "macos")]
fn keychain_get() -> Result<Option<Zeroizing<String>>> {
let asking = interactive();
let _quiet = if asking {
None
} else {
security_framework::os::macos::keychain::SecKeychain::disable_user_interaction().ok()
};
match Self::unchecked_entry()?.get_password() {
Ok(value) => Ok(Some(Zeroizing::new(value))),
Err(keyring::Error::NoEntry) => Ok(None),
Err(_) if asking => bail!(
"Cannot read the system credential store; unlock it or run jevgate auth login"
),
Err(_) => bail!(
"macOS asks before this jevgate reads the key saved by jevgate auth login, which it cannot without a terminal; run jevgate auth status in a terminal once and choose Always Allow, or set TYPESAFE_API_KEY"
),
}
}
}
impl Backend for NativeBackend {
fn get(&self) -> Result<Option<Zeroizing<String>>> {
Expand All @@ -52,9 +89,14 @@ impl Backend for NativeBackend {
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
))]
return super::native_unix::get();
#[cfg(not(all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
#[cfg(target_os = "macos")]
return Self::keychain_get();
#[cfg(not(any(
target_os = "macos",
all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
)
)))]
match self.entry()?.get_password() {
Ok(value) => Ok(Some(Zeroizing::new(value))),
Expand Down
Loading
Loading